mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(mobile): contain root render failures
This commit is contained in:
+19
-1
@@ -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) })
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user