diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 9080cdedcf9..a1ae162061d 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -7,6 +7,7 @@ import * as Notifications from 'expo-notifications' import * as Linking from 'expo-linking' import { colors } from '../src/theme/mobile-theme' import { OrcaLogo } from '../src/components/OrcaLogo' +import { MobileRootErrorBoundary } from '../src/components/MobileRootErrorBoundary' import { RpcClientProvider } from '../src/transport/client-context' import { getNotificationNavigationTarget } from '../src/notifications/notification-routing' import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route' @@ -33,7 +34,7 @@ Notifications.setNotificationHandler({ }) }) -export default function RootLayout() { +function RootLayoutContents() { const router = useRouter() const openNotificationRoute = useOpenNotificationRoute() const handledNotificationIdsRef = useRef>(new Set()) @@ -205,6 +206,23 @@ export default function RootLayout() { ) } +export default function RootLayout() { + const router = useRouter() + const leaveFailedRoute = useCallback(() => { + if (router.canGoBack()) { + router.back() + return + } + router.replace('/') + }, [router]) + + return ( + + + + ) +} + const styles = StyleSheet.create({ root: { flex: 1, diff --git a/mobile/src/components/MobileRootErrorBoundary.tsx b/mobile/src/components/MobileRootErrorBoundary.tsx new file mode 100644 index 00000000000..7985147f26a --- /dev/null +++ b/mobile/src/components/MobileRootErrorBoundary.tsx @@ -0,0 +1,161 @@ +import { Component, Fragment, type ErrorInfo, type ReactNode } from 'react' +import { AlertTriangle, ArrowLeft, RefreshCw } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import { colors, radii, spacing, typography } from '../theme/mobile-theme' + +type Props = { + children: ReactNode + onGoBack: () => void +} + +type State = { + error: Error | null + resetKey: number +} + +export class MobileRootErrorBoundary extends Component { + state: State = { error: null, resetKey: 0 } + + static getDerivedStateFromError(error: Error): Partial { + return { error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error( + '[mobile-root-error-boundary] render crash contained by boundary', + error, + errorInfo + ) + } + + handleRetry = (): void => { + this.setState(({ resetKey }) => ({ error: null, resetKey: resetKey + 1 })) + } + + handleGoBack = (): void => { + this.props.onGoBack() + this.handleRetry() + } + + render(): ReactNode { + if (this.state.error) { + return + } + + return {this.props.children} + } +} + +function MobileRootErrorFallback({ + onRetry, + onGoBack +}: { + onRetry: () => void + onGoBack: () => void +}): ReactNode { + return ( + + + + + + This part of Orca hit an error. + + The app is still running. Retry this screen or go back and continue elsewhere. + + + + + + Retry + + + + Go back + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: spacing.lg, + padding: spacing.xl, + backgroundColor: colors.bgBase + }, + iconBadge: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + borderRadius: 22, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.statusRed, + backgroundColor: colors.bgPanel + }, + copy: { + alignItems: 'center', + gap: spacing.sm, + maxWidth: 420 + }, + title: { + color: colors.textPrimary, + fontSize: typography.titleSize, + fontWeight: '600', + textAlign: 'center' + }, + description: { + color: colors.textSecondary, + fontSize: typography.bodySize, + lineHeight: 20, + textAlign: 'center' + }, + actions: { + width: '100%', + maxWidth: 360, + gap: spacing.sm + }, + primaryButton: { + minHeight: 40, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.xl, + borderRadius: radii.button, + backgroundColor: colors.surfaceBright + }, + primaryButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '600' + }, + secondaryButton: { + minHeight: 40, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.xl, + borderRadius: radii.button + }, + secondaryButtonText: { + color: colors.textSecondary, + fontSize: typography.bodySize, + fontWeight: '500' + } +}) diff --git a/mobile/src/components/mobile-root-error-boundary.test.tsx b/mobile/src/components/mobile-root-error-boundary.test.tsx new file mode 100644 index 00000000000..22ebbb5734f --- /dev/null +++ b/mobile/src/components/mobile-root-error-boundary.test.tsx @@ -0,0 +1,92 @@ +import { createElement, type ReactNode } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + hideSplash: vi.fn().mockResolvedValue(undefined), + preventAutoHide: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ + AlertTriangle: 'AlertTriangle', + ArrowLeft: 'ArrowLeft', + RefreshCw: 'RefreshCw' +})) +vi.mock('expo-router', () => { + function Stack(): never { + throw new Error('route render exploded') + } + Stack.Screen = 'StackScreen' + return { + Stack, + useRouter: () => ({ back: vi.fn(), canGoBack: () => true, replace: vi.fn() }) + } +}) +vi.mock('expo-status-bar', () => ({ StatusBar: 'StatusBar' })) +vi.mock('expo-splash-screen', () => ({ + hideAsync: mocks.hideSplash, + preventAutoHideAsync: mocks.preventAutoHide +})) +vi.mock('expo-notifications', () => ({ + DEFAULT_ACTION_IDENTIFIER: 'default', + addNotificationResponseReceivedListener: () => ({ remove: vi.fn() }), + clearLastNotificationResponse: vi.fn(), + getLastNotificationResponse: () => null, + setNotificationHandler: vi.fn() +})) +vi.mock('expo-linking', () => ({ + addEventListener: () => ({ remove: vi.fn() }), + getInitialURL: vi.fn().mockResolvedValue(null) +})) +vi.mock('./OrcaLogo', () => ({ OrcaLogo: 'OrcaLogo' })) +vi.mock('../transport/client-context', () => ({ + RpcClientProvider: ({ children }: { children: ReactNode }) => children +})) +vi.mock('../notifications/notification-routing', () => ({ + getNotificationNavigationTarget: vi.fn() +})) +vi.mock('../notifications/use-open-notification-route', () => ({ + useOpenNotificationRoute: () => vi.fn() +})) +vi.mock('../transport/host-store', () => ({ loadHostCatalog: vi.fn().mockResolvedValue([]) })) +vi.mock('../transport/pairing', () => ({ extractPairingCodeFromUrl: () => null })) +vi.mock('../transport/mobile-relay-pairing-recovery', () => ({ + recoverMobileRelayPairing: vi.fn().mockResolvedValue(undefined) +})) + +import RootLayout from '../../app/_layout' + +describe('mobile root error boundary', () => { + let renderer: ReactTestRenderer | null = null + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.restoreAllMocks() + }) + + it('contains a route render failure and shows recovery actions', () => { + vi.spyOn(console, 'error').mockImplementation(() => undefined) + + act(() => { + renderer = create(createElement(RootLayout)) + }) + + const fallback = renderer.root.findByProps({ testID: 'mobile-root-error-boundary' }) + expect( + fallback.findAllByType('Pressable').map((button) => button.props.accessibilityLabel) + ).toEqual(['Retry', 'Go back']) + expect(console.error).toHaveBeenCalledWith( + '[mobile-root-error-boundary] render crash contained by boundary', + expect.any(Error), + expect.objectContaining({ componentStack: expect.any(String) }) + ) + }) +})