diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx
index 444a1b9a12..3043b212e6 100644
--- a/apps/mobile/src/app/_layout.tsx
+++ b/apps/mobile/src/app/_layout.tsx
@@ -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';
@@ -1085,11 +1085,7 @@ function RootLayout() {
}
function RootErrorBoundary({ retry }: ErrorBoundaryProps) {
- return (
-
- void retry()} />
-
- );
+ return void retry()} />;
}
export const ErrorBoundary = Sentry.wrapExpoRouterErrorBoundary(RootErrorBoundary);
diff --git a/apps/mobile/src/components/empty-state.tsx b/apps/mobile/src/components/empty-state.tsx
index 9ac735e18e..0b3f4d3432 100644
--- a/apps/mobile/src/components/empty-state.tsx
+++ b/apps/mobile/src/components/empty-state.tsx
@@ -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;
diff --git a/apps/mobile/src/components/query-error.tsx b/apps/mobile/src/components/query-error.tsx
index 2369a48310..89108b4423 100644
--- a/apps/mobile/src/components/query-error.tsx
+++ b/apps/mobile/src/components/query-error.tsx
@@ -51,7 +51,7 @@ type QueryErrorProps = {
onRetry?: () => void;
isRetrying?: boolean;
className?: string;
- placement?: 'center' | 'top';
+ placement?: 'center' | 'top' | 'static';
refreshControl?: ScrollViewProps['refreshControl'];
};
diff --git a/apps/mobile/src/components/runtime-error-screen.mounted.test.tsx b/apps/mobile/src/components/runtime-error-screen.mounted.test.tsx
new file mode 100644
index 0000000000..2689344109
--- /dev/null
+++ b/apps/mobile/src/components/runtime-error-screen.mounted.test.tsx
@@ -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) => {
+ 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();
+
+ // 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();
+ });
+});
diff --git a/apps/mobile/src/components/runtime-error-screen.tsx b/apps/mobile/src/components/runtime-error-screen.tsx
new file mode 100644
index 0000000000..9163471b2b
--- /dev/null
+++ b/apps/mobile/src/components/runtime-error-screen.tsx
@@ -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 (
+
+
+
+ );
+}