Skip to content

Commit 4f31ffe

Browse files
msfstefclaude
andcommitted
Add error boundary and OAuth callback timeout to agents-mobile
Two robustness fixes for flows a store reviewer is likely to hit: - Wrap the app provider tree in Sentry.ErrorBoundary (inside ThemeProvider so the fallback is themed) with a "Try again" screen. An uncaught render error otherwise shows a blank/native crash screen, which is an automatic review rejection. Sentry still reports the error. - Give the /oauth/callback route a 10s escape hatch. A cold start onto the callback with no pending request could spin on "Finishing sign-in…" forever; now it surfaces a "Back to sign-in" action. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9a9d918 commit 4f31ffe

2 files changed

Lines changed: 99 additions & 7 deletions

File tree

packages/agents-mobile/app/_layout.tsx

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { Redirect, Stack, usePathname } from 'expo-router'
22
import { StatusBar } from 'expo-status-bar'
3-
import { ActivityIndicator, StyleSheet, View } from 'react-native'
3+
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
44
import { GestureHandlerRootView } from 'react-native-gesture-handler'
55
import {
66
SafeAreaProvider,
77
initialWindowMetrics,
88
} from 'react-native-safe-area-context'
99
import { AgentsProvider } from '../src/lib/AgentsProvider'
10+
import { PrimaryButton } from '../src/components/PrimaryButton'
1011
import {
1112
ThemeProvider,
1213
useColorSchemeMode,
@@ -18,6 +19,7 @@ import {
1819
} from '../src/lib/MobileAppState'
1920
import { CloudAuthProvider } from '../src/lib/CloudAuthContext'
2021
import { isCallbackUrl } from '../src/lib/cloudAuth'
22+
import { fontSize, lineHeight, spacing } from '../src/lib/theme'
2123
import { Sentry, initSentry } from '../src/lib/sentry'
2224

2325
// Initialize early so startup crashes are captured (no-op in dev).
@@ -28,17 +30,48 @@ function RootLayout(): React.ReactElement {
2830
<GestureHandlerRootView style={styles.root}>
2931
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
3032
<ThemeProvider>
31-
<MobileAppStateProvider>
32-
<CloudAuthProvider>
33-
<RootNavigator />
34-
</CloudAuthProvider>
35-
</MobileAppStateProvider>
33+
{/* Inside ThemeProvider so the themed fallback turns an uncaught
34+
render error into a recoverable screen, not a blank crash. */}
35+
<Sentry.ErrorBoundary
36+
fallback={({ resetError }) => (
37+
<RootErrorFallback onRetry={resetError} />
38+
)}
39+
>
40+
<MobileAppStateProvider>
41+
<CloudAuthProvider>
42+
<RootNavigator />
43+
</CloudAuthProvider>
44+
</MobileAppStateProvider>
45+
</Sentry.ErrorBoundary>
3646
</ThemeProvider>
3747
</SafeAreaProvider>
3848
</GestureHandlerRootView>
3949
)
4050
}
4151

52+
function RootErrorFallback({
53+
onRetry,
54+
}: {
55+
onRetry: () => void
56+
}): React.ReactElement {
57+
const tokens = useTokens()
58+
return (
59+
<View style={[styles.fallback, { backgroundColor: tokens.bg }]}>
60+
<StatusBar style="light" />
61+
<Text style={[styles.fallbackTitle, { color: tokens.text1 }]}>
62+
Something went wrong
63+
</Text>
64+
<Text style={[styles.fallbackBody, { color: tokens.text2 }]}>
65+
The app hit an unexpected error. You can try again — if it keeps
66+
happening, reopen the app.
67+
</Text>
68+
<View style={styles.fallbackAction}>
69+
<PrimaryButton title="Try again" onPress={onRetry} />
70+
</View>
71+
</View>
72+
)
73+
}
74+
4275
export default Sentry.wrap(RootLayout)
4376

4477
function RootNavigator(): React.ReactElement {
@@ -115,4 +148,26 @@ const styles = StyleSheet.create({
115148
alignItems: `center`,
116149
justifyContent: `center`,
117150
},
151+
fallback: {
152+
flex: 1,
153+
alignItems: `center`,
154+
justifyContent: `center`,
155+
paddingHorizontal: spacing.xl,
156+
gap: spacing.md,
157+
},
158+
fallbackTitle: {
159+
fontSize: fontSize.xl,
160+
fontWeight: `600`,
161+
lineHeight: lineHeight.xl,
162+
textAlign: `center`,
163+
},
164+
fallbackBody: {
165+
fontSize: fontSize.sm,
166+
lineHeight: lineHeight.sm,
167+
textAlign: `center`,
168+
},
169+
fallbackAction: {
170+
alignSelf: `stretch`,
171+
marginTop: spacing.sm,
172+
},
118173
})

packages/agents-mobile/app/oauth/callback.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { useEffect, useMemo, useRef, useState } from 'react'
22
import * as Linking from 'expo-linking'
3-
import { Redirect, useLocalSearchParams } from 'expo-router'
3+
import { Redirect, useLocalSearchParams, useRouter } from 'expo-router'
44
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'
55
import {
66
cloudAuth,
77
isCallbackUrl,
88
type CloudAuthState,
99
} from '../../src/lib/cloudAuth'
10+
import { PrimaryButton } from '../../src/components/PrimaryButton'
1011
import { useMobileAppState } from '../../src/lib/MobileAppState'
1112
import { useTokens } from '../../src/lib/ThemeProvider'
1213
import { fontSize, lineHeight, spacing } from '../../src/lib/theme'
@@ -50,6 +51,7 @@ type RouteStatus =
5051

5152
export default function OAuthCallbackRoute(): React.ReactElement {
5253
const params = useLocalSearchParams()
54+
const router = useRouter()
5355
const tokens = useTokens()
5456
const styles = useMemo(() => createStyles(tokens), [tokens])
5557
const { serverUrl, onboardingDismissed } = useMobileAppState()
@@ -63,6 +65,17 @@ export default function OAuthCallbackRoute(): React.ReactElement {
6365
deriveStatus(cloudAuth.getState())
6466
)
6567

68+
// Escape hatch: a cold start onto `/oauth/callback` with no pending
69+
// request (or a redirect that never arrives) would otherwise spin on
70+
// "Finishing sign-in…" forever. After a grace period, surface a way
71+
// back to the sign-in screen.
72+
const [tookTooLong, setTookTooLong] = useState(false)
73+
useEffect(() => {
74+
if (status.kind !== `working`) return
75+
const timer = setTimeout(() => setTookTooLong(true), 10_000)
76+
return () => clearTimeout(timer)
77+
}, [status.kind])
78+
6679
useEffect(() => {
6780
// Sync once on mount in case the state changed between the
6881
// `useState` initializer and this effect attaching (very narrow
@@ -137,6 +150,18 @@ export default function OAuthCallbackRoute(): React.ReactElement {
137150
<View style={styles.root}>
138151
<ActivityIndicator color={tokens.accent11} />
139152
<Text style={styles.text}>Finishing sign-in…</Text>
153+
{tookTooLong ? (
154+
<View style={styles.escape}>
155+
<Text style={styles.subtext}>
156+
This is taking longer than expected.
157+
</Text>
158+
<PrimaryButton
159+
title="Back to sign-in"
160+
variant="soft"
161+
onPress={() => router.replace(`/onboarding`)}
162+
/>
163+
</View>
164+
) : null}
140165
</View>
141166
)
142167
}
@@ -191,5 +216,17 @@ function createStyles(tokens: Tokens) {
191216
lineHeight: lineHeight.base,
192217
textAlign: `center`,
193218
},
219+
escape: {
220+
alignSelf: `stretch`,
221+
alignItems: `center`,
222+
gap: spacing.sm,
223+
marginTop: spacing.lg,
224+
},
225+
subtext: {
226+
color: tokens.text3,
227+
fontSize: fontSize.sm,
228+
lineHeight: lineHeight.sm,
229+
textAlign: `center`,
230+
},
194231
})
195232
}

0 commit comments

Comments
 (0)