fix(mobile): contain root render failures

This commit is contained in:
Brennan Benson
2026-08-24 19:08:25 -07:00
parent cc4801320a
commit a87727670c
3 changed files with 272 additions and 1 deletions
+19 -1
View File
@@ -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<Set<string>>(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 (
<MobileRootErrorBoundary onGoBack={leaveFailedRoute}>
<RootLayoutContents />
</MobileRootErrorBoundary>
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
@@ -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<Props, State> {
state: State = { error: null, resetKey: 0 }
static getDerivedStateFromError(error: Error): Partial<State> {
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 <MobileRootErrorFallback onRetry={this.handleRetry} onGoBack={this.handleGoBack} />
}
return <Fragment key={this.state.resetKey}>{this.props.children}</Fragment>
}
}
function MobileRootErrorFallback({
onRetry,
onGoBack
}: {
onRetry: () => void
onGoBack: () => void
}): ReactNode {
return (
<View style={styles.container} accessibilityRole="alert" testID="mobile-root-error-boundary">
<View style={styles.iconBadge}>
<AlertTriangle size={20} color={colors.statusRed} />
</View>
<View style={styles.copy}>
<Text style={styles.title}>This part of Orca hit an error.</Text>
<Text style={styles.description}>
The app is still running. Retry this screen or go back and continue elsewhere.
</Text>
</View>
<View style={styles.actions}>
<Pressable
accessibilityLabel="Retry"
accessibilityRole="button"
style={styles.primaryButton}
onPress={onRetry}
>
<RefreshCw size={16} color={colors.bgBase} />
<Text style={styles.primaryButtonText}>Retry</Text>
</Pressable>
<Pressable
accessibilityLabel="Go back"
accessibilityRole="button"
style={styles.secondaryButton}
onPress={onGoBack}
>
<ArrowLeft size={16} color={colors.textSecondary} />
<Text style={styles.secondaryButtonText}>Go back</Text>
</Pressable>
</View>
</View>
)
}
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'
}
})
@@ -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: <T,>(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) })
)
})
})