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 ( + + + + ); +}