diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 444a1b9a12..988e082fe2 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -46,6 +46,7 @@ import { toast } from 'sonner-native';
import { AnimatedSplashOverlay } from '@/components/animated-splash-overlay';
import { AppRootProviders } from '@/components/app-root-providers';
import { BootstrapErrorScreen } from '@/components/bootstrap-error-screen';
+import { BootstrapLoadingSurface } from '@/components/bootstrap-loading-surface';
import { OfflineBannerSpaceGate } from '@/components/offline-banner';
import { StateSurface } from '@/components/centered-state-surface';
import { LanguageReloadErrorScreen } from '@/components/language-reload-error-screen';
@@ -54,7 +55,7 @@ import { splashContentScale } from '@/components/splash-reveal';
import { announceForA11y, moveA11yFocus } from '@/lib/a11y/announce';
import { MotionProvider } from '@/lib/a11y/motion';
import { useAuth } from '@/lib/auth/auth-context';
-import { resolveBootstrapDecision } from '@/lib/bootstrap-decision';
+import { resolveBootstrapDecision, shouldShowBootstrapLoading } from '@/lib/bootstrap-decision';
import { consentModeForSearchParam } from '@/components/consent/consent-mode';
import { checkConsentGate } from '@/lib/consent-gate';
import { subscribeToConsentChanges } from '@/lib/consent';
@@ -844,6 +845,12 @@ function RootLayoutNav({
restoreFailed,
});
+ // Post-startup hidden windows (a sign-in's redirect + consent check, a
+ // sign-out's redirect to login) have no splash over them, so the hidden
+ // wrapper would otherwise paint an empty background. Keep one spinner up
+ // for exactly those windows (app-blank-after-oauth).
+ const showBootstrapLoading = shouldShowBootstrapLoading({ startupFinished, hidden });
+
// Hidden root-route entry contract (D17): while `hidden`, the wrapper leaves
// both accessibility trees. On the hidden → visible transition,
// `announceForA11y` is the deterministic entry context for screen-reader
@@ -991,6 +998,7 @@ function RootLayoutNav({
>
+ {showBootstrapLoading && !showRestoreError ? : null}
{showRestoreError ? (
({
+ View: 'View',
+}));
+vi.mock('@/components/ui/activity-indicator', () => ({ ActivityIndicator: 'ActivityIndicator' }));
+vi.mock('@/lib/hooks/use-theme-colors', () => ({
+ useThemeColors: () => ({ mutedForeground: '#71717a' }),
+}));
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+// ── Helpers ────────────────────────────────────────────────────────────────
+
+async function mountSurface(): Promise {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ await act(async () => {
+ ref.current = TestRenderer.create(createElement(BootstrapLoadingSurface));
+ await Promise.resolve();
+ });
+ const renderer = ref.current;
+ if (!renderer) {
+ throw new Error('renderer was not created');
+ }
+ return renderer;
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────
+
+describe('BootstrapLoadingSurface', () => {
+ beforeEach(() => {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ });
+
+ it('renders one spinner in a progressbar surface covering the screen', async () => {
+ const renderer = await mountSurface();
+
+ const surface = renderer.root.findByProps({ accessibilityRole: 'progressbar' });
+ expect(surface.props.accessibilityLabel).toBe('common.loading');
+ expect(surface.props.accessibilityState).toEqual({ busy: true });
+ expect(surface.props.className).toContain('absolute inset-0');
+ expect(surface.props.className).toContain('bg-background');
+
+ // One indicator, not a stack: the surface owns the whole wait.
+ expect(
+ renderer.root.findAll(
+ node => typeof node.type === 'string' && (node.type as string) === 'ActivityIndicator'
+ )
+ ).toHaveLength(1);
+
+ renderer.unmount();
+ });
+});
diff --git a/apps/mobile/src/components/bootstrap-loading-surface.tsx b/apps/mobile/src/components/bootstrap-loading-surface.tsx
new file mode 100644
index 0000000000..1dbc91b138
--- /dev/null
+++ b/apps/mobile/src/components/bootstrap-loading-surface.tsx
@@ -0,0 +1,37 @@
+import { useTranslation } from 'react-i18next';
+import { View } from 'react-native';
+
+import { ActivityIndicator } from '@/components/ui/activity-indicator';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+/**
+ * Full-screen loading surface for the post-startup bootstrap window.
+ *
+ * The root layout hides the navigation tree (`opacity-0`) while it resolves a
+ * redirect or the account's consent state, so the screen being left is never
+ * shown. On a cold start the native splash covers that window; once startup has
+ * settled — after a sign-in (or a sign-out) the splash is already gone — the
+ * hidden tree painted an empty `bg-background` with no content, spinner, or
+ * message, which read as a broken screen (explorer app-blank-after-oauth).
+ *
+ * This surface keeps one spinner in that window. It is rendered under
+ * `AnimatedSplashOverlay`, so during a launch that is still revealing the
+ * splash is the visible indicator and this one only appears after the
+ * handover.
+ */
+export function BootstrapLoadingSurface() {
+ const { t } = useTranslation();
+ const colors = useThemeColors();
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/lib/bootstrap-decision.test.ts b/apps/mobile/src/lib/bootstrap-decision.test.ts
index 3787ffd82f..d965e8e724 100644
--- a/apps/mobile/src/lib/bootstrap-decision.test.ts
+++ b/apps/mobile/src/lib/bootstrap-decision.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { resolveBootstrapDecision } from './bootstrap-decision';
+import { resolveBootstrapDecision, shouldShowBootstrapLoading } from './bootstrap-decision';
// The settled, signed-in, consent-granted, app-ready state: every guard passes
// and the decision falls through to `settle-app`.
@@ -250,3 +250,29 @@ describe('resolveBootstrapDecision derivations', () => {
).toBe(false);
});
});
+
+describe('shouldShowBootstrapLoading', () => {
+ it('paints the loading surface only for a hidden tree after startup settled', () => {
+ expect(shouldShowBootstrapLoading({ startupFinished: true, hidden: true })).toBe(true);
+ });
+
+ it('stays off while the splash covers the initial launch', () => {
+ expect(shouldShowBootstrapLoading({ startupFinished: false, hidden: true })).toBe(false);
+ });
+
+ it('stays off when the tree is visible', () => {
+ expect(shouldShowBootstrapLoading({ startupFinished: true, hidden: false })).toBe(false);
+ expect(shouldShowBootstrapLoading({ startupFinished: false, hidden: false })).toBe(false);
+ });
+
+ it('covers the post-sign-in window: settled startup, hidden tree', () => {
+ // The explorer capture (app-blank-after-oauth): the login screen has
+ // completed startup, the token is published, and the redirect + consent
+ // check hide the tree with no splash over it.
+ const decision = resolveBootstrapDecision({ ...ready, consentChecked: false });
+ expect(decision.hidden).toBe(true);
+ expect(shouldShowBootstrapLoading({ startupFinished: true, hidden: decision.hidden })).toBe(
+ true
+ );
+ });
+});
diff --git a/apps/mobile/src/lib/bootstrap-decision.ts b/apps/mobile/src/lib/bootstrap-decision.ts
index 8b86cc03f9..81fb8019f5 100644
--- a/apps/mobile/src/lib/bootstrap-decision.ts
+++ b/apps/mobile/src/lib/bootstrap-decision.ts
@@ -92,6 +92,23 @@ function resolveBootstrapTag(input: BootstrapDecisionInput): BootstrapDecisionTa
return 'settle-app';
}
+/**
+ * Whether the hidden tree needs the loading surface painted over it.
+ *
+ * `hidden` is true during the initial launch too, but the native splash and
+ * `AnimatedSplashOverlay` cover that window. Once startup has settled (the
+ * splash has handed over), a hidden tree is an exposed empty background: the
+ * post-sign-in redirect/consent window is the reported case
+ * (app-blank-after-oauth). `startupFinished` is the splash handover signal, so
+ * the surface is only requested for the windows the splash no longer covers.
+ */
+export function shouldShowBootstrapLoading(input: {
+ readonly startupFinished: boolean;
+ readonly hidden: boolean;
+}): boolean {
+ return input.startupFinished && input.hidden;
+}
+
export function resolveBootstrapDecision(input: BootstrapDecisionInput): BootstrapDecision {
const hasUserBootstrapError = input.hasToken && input.userIdError;
const hasConsentBootstrapError = input.hasToken && input.consentCheckError;
diff --git a/apps/mobile/src/lib/startup-order.test.ts b/apps/mobile/src/lib/startup-order.test.ts
index de19cf6ff8..26f9abaa0f 100644
--- a/apps/mobile/src/lib/startup-order.test.ts
+++ b/apps/mobile/src/lib/startup-order.test.ts
@@ -41,6 +41,18 @@ describe('root layout startup order (text contract)', () => {
).toBe(true);
});
+ it('excludes the held restore-error surface from bootstrap loading', () => {
+ // A successful retry clears restoreFailed before the hidden gate settles.
+ // The held error screen still owns feedback, including its inline spinner.
+ const excludesHeldError =
+ /\{showBootstrapLoading\s*&&\s*!showRestoreError\s*\?\s*\s*:\s*null\}/.test(
+ stripComments(layoutSource)
+ );
+ expect(excludesHeldError, 'the held restore error must exclude BootstrapLoadingSurface').toBe(
+ true
+ );
+ });
+
// The persisted deep-link record is account-bound. Auth bootstrap publishes
// the signed-in user id before it clears `authLoading`, so a restore that
// runs on an empty dependency array reads a null user id and deletes the