Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -991,6 +998,7 @@ function RootLayoutNav({
>
<Slot />
</View>
{showBootstrapLoading && !showRestoreError ? <BootstrapLoadingSurface /> : null}
{showRestoreError ? (
<View className="absolute inset-0">
<BootstrapErrorScreen
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { createElement } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { BootstrapLoadingSurface } from '@/components/bootstrap-loading-surface';

// ── Hoisted mocks ──────────────────────────────────────────────────────────

vi.mock('react-native', () => ({
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<TestRenderer.ReactTestRenderer> {
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();
});
});
37 changes: 37 additions & 0 deletions apps/mobile/src/components/bootstrap-loading-surface.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
accessible
accessibilityRole="progressbar"
accessibilityLabel={t('common.loading')}
accessibilityState={{ busy: true }}
className="absolute inset-0 items-center justify-center bg-background"
>
<ActivityIndicator color={colors.mutedForeground} />
</View>
);
}
28 changes: 27 additions & 1 deletion apps/mobile/src/lib/bootstrap-decision.test.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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
);
});
});
17 changes: 17 additions & 0 deletions apps/mobile/src/lib/bootstrap-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/lib/startup-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*<BootstrapLoadingSurface\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
Expand Down
Loading