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