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
8 changes: 2 additions & 6 deletions apps/mobile/src/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { BootstrapErrorScreen } from '@/components/bootstrap-error-screen';
import { OfflineBannerSpaceGate } from '@/components/offline-banner';
import { StateSurface } from '@/components/centered-state-surface';
import { LanguageReloadErrorScreen } from '@/components/language-reload-error-screen';
import { QueryError } from '@/components/query-error';
import { RuntimeErrorScreen } from '@/components/runtime-error-screen';
import { splashContentScale } from '@/components/splash-reveal';
import { announceForA11y, moveA11yFocus } from '@/lib/a11y/announce';
import { MotionProvider } from '@/lib/a11y/motion';
Expand Down Expand Up @@ -1085,11 +1085,7 @@ function RootLayout() {
}

function RootErrorBoundary({ retry }: ErrorBoundaryProps) {
return (
<StateSurface className="flex-1 bg-background">
<QueryError onRetry={() => void retry()} />
</StateSurface>
);
return <RuntimeErrorScreen onRetry={() => void retry()} />;
}

export const ErrorBoundary = Sentry.wrapExpoRouterErrorBoundary(RootErrorBoundary);
Expand Down
6 changes: 5 additions & 1 deletion apps/mobile/src/components/empty-state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ type EmptyStateProps = {
description: ReactNode;
className?: string;
action?: ReactNode;
placement?: 'center' | 'top';
/** `center` scrolls the content inside a measured `StateSurface`; `top`
* pins it to the surface top without a scroller; `static` renders the plain
* content for a caller that owns its own full-screen layout and cannot
* depend on a measured surface (the root runtime-error screen). */
placement?: 'center' | 'top' | 'static';
refreshControl?: ScrollViewProps['refreshControl'];
/** Overrides the icon bubble's container classes (size/shape/background). Defaults to the card-style bubble. */
iconContainerClassName?: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/components/query-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type QueryErrorProps = {
onRetry?: () => void;
isRetrying?: boolean;
className?: string;
placement?: 'center' | 'top';
placement?: 'center' | 'top' | 'static';
refreshControl?: ScrollViewProps['refreshControl'];
};

Expand Down
119 changes: 119 additions & 0 deletions apps/mobile/src/components/runtime-error-screen.mounted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { type ComponentPropsWithRef, createElement, useImperativeHandle } from 'react';
import { type ScrollView } from 'react-native';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { renderWithProviders } from '@/test/render-with-providers';

import { RuntimeErrorScreen } from './runtime-error-screen';
import { AlertCircle } from './ui/icons';

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

// The device's native surface observer has not published a usable geometry for
// the just-mounted error surface (the Android module reports a surface it
// cannot see as `visibleTop === visibleBottom`; a pending observation reports
// nothing at all). Under that snapshot the measured centering pipeline has no
// frame and withholds everything it owns. The runtime-error screen must paint
// regardless.
const geometry = vi.hoisted(() => ({
status: 'ready' as const,
geometry: {
tag: 41,
visibleTop: 0,
visibleBottom: 0,
boundsHeight: 800,
safeAreaTop: 0,
safeAreaBottom: 0,
},
}));
vi.mock('@/lib/hooks/use-native-state-geometry', () => ({
useNativeStateGeometry: () => geometry,
}));

vi.mock('react-native', () => ({
PixelRatio: { roundToNearestPixel: (value: number) => Math.round(value * 2) / 2 },
Platform: { OS: 'android' },
useWindowDimensions: () => ({ width: 400, height: 800 }),
View: 'View',
ScrollView: (props: ComponentPropsWithRef<typeof ScrollView>) => {
const { ref, ...rest } = props;
useImperativeHandle(
ref,
() =>
({
getNativeScrollRef: () => ({ measureInWindow: () => undefined }),
}) as unknown as ScrollView,
[]
);
return createElement('ScrollView', rest);
},
}));

vi.mock('@/lib/utils', () => ({ cn: (...values: unknown[]) => values.filter(Boolean).join(' ') }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
vi.mock('@/components/ui/accessible-status', () => ({ AccessibleStatus: 'AccessibleStatus' }));
vi.mock('@/components/ui/icons', () => ({
AlertCircle: () => null,
Loader2: () => null,
Lock: () => null,
SearchX: () => null,
ServerCrash: () => null,
WifiOff: () => null,
}));
vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ mutedForeground: '#777777' }),
}));
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));

// ── Helpers ────────────────────────────────────────────────────────────────

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});

afterEach(() => {
vi.unstubAllGlobals();
});

// ── Tests ──────────────────────────────────────────────────────────────────

describe('RuntimeErrorScreen', () => {
it('paints its message, description and next action with no usable surface geometry', async () => {
const onRetry = vi.fn<() => void>();
const mounted = await renderWithProviders(<RuntimeErrorScreen onRetry={onRetry} />);

// Nothing may be withheld: the centering pipeline hides its content with
// `accessibilityElementsHidden` + `opacity-0` until it has measured, which
// is the blank frame this screen must never show.
const hidden = mounted.renderer.root.findAll(
node => node.props.accessibilityElementsHidden === true
);
expect(hidden).toHaveLength(0);

const labels = mounted.renderer.root
.findAll(node => typeof node.props.children === 'string')
.map(node => node.props.children as string);
expect(labels).toContain('common.somethingWentWrong');
expect(labels).toContain('common.retry');

// The failure icon and the description are the designed error body.
expect(mounted.renderer.root.findAllByType(AlertCircle)).toHaveLength(1);
expect(
mounted.renderer.root.findAll(node => node.props.message === 'queryError.neutralDescription')
).toHaveLength(1);

const retry = mounted.renderer.root.findAll(
node => node.props.accessibilityLabel === 'common.retry'
);
expect(retry).toHaveLength(1);
const retryButton = retry[0];
if (!retryButton) {
throw new TypeError('Expected a retry control');
}
(retryButton.props.onPress as () => void)();
expect(onRetry).toHaveBeenCalledOnce();

mounted.unmount();
});
});
26 changes: 26 additions & 0 deletions apps/mobile/src/components/runtime-error-screen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { View } from 'react-native';

import { QueryError } from '@/components/query-error';

type RuntimeErrorScreenProps = {
readonly onRetry: () => void;
};

/**
* Last-resort screen for a render error that reached the app's root error
* boundary (expo-router `ErrorBoundary` in `src/app/_layout.tsx`).
*
* It owns a plain full-screen layout instead of a measured `StateSurface`: the
* centering pipeline withholds its content until the native surface observer
* reports a visible geometry, and a boundary that rendered behind that gate was
* a blank frame whenever the snapshot never arrived (or arrived with the
* surface reported invisible). An error screen must paint immediately and
* must not depend on the machinery that may have just failed.
*/
export function RuntimeErrorScreen({ onRetry }: RuntimeErrorScreenProps) {
return (
<View className="flex-1 items-center justify-center bg-background">
<QueryError placement="static" className="w-full" onRetry={onRetry} />
</View>
);
}
Loading