fix(mobile): surface previous crash on relaunch

This commit is contained in:
Brennan Benson
2026-08-27 12:53:18 -07:00
parent 770ae57745
commit 6cd351420a
9 changed files with 278 additions and 13 deletions
+2
View File
@@ -8,6 +8,7 @@ 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 { PreviousCrashSessionLaunchNotice } from '../src/components/PreviousCrashSessionLaunchNotice'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
@@ -184,6 +185,7 @@ function RootLayoutContents() {
<RpcClientProvider>
<View style={styles.root} onLayout={onNavigatorLayout}>
<StatusBar style="light" />
<PreviousCrashSessionLaunchNotice />
<Stack
screenOptions={{
headerStyle: { backgroundColor: colors.bgPanel },
+2 -1
View File
@@ -213,7 +213,7 @@ export default function TroubleshootScreen() {
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{hasPreviousCrashSession && <PreviousCrashSessionBanner />}
{hasPreviousCrashSession && <PreviousCrashSessionBanner style={styles.crashBanner} />}
<Pressable
style={({ pressed }) => [
@@ -320,6 +320,7 @@ export default function TroubleshootScreen() {
}
const styles = StyleSheet.create({
crashBanner: { marginBottom: spacing.lg },
container: {
flex: 1,
backgroundColor: colors.bgBase,
@@ -1,17 +1,53 @@
import { AlertTriangle } from 'lucide-react-native'
import { StyleSheet, Text, View } from 'react-native'
import { AlertTriangle, ChevronRight, X } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export function PreviousCrashSessionBanner() {
export function PreviousCrashSessionBanner({
onDismiss,
onPress,
style
}: {
onDismiss?: () => void
onPress?: () => void
style?: StyleProp<ViewStyle>
}) {
const copy = (
<View style={styles.copy}>
<Text style={styles.title}>Previous session ended abnormally</Text>
<Text style={styles.description}>
Crash diagnostics are available to copy and share with support.
</Text>
{onPress ? <Text style={styles.actionText}>View diagnostics</Text> : null}
</View>
)
return (
<View style={styles.banner} testID="previous-crash-session-banner">
<View style={[styles.banner, style]} testID="previous-crash-session-banner">
<AlertTriangle size={16} color={colors.statusAmber} />
<View style={styles.copy}>
<Text style={styles.title}>Previous session ended abnormally</Text>
<Text style={styles.description}>
Crash diagnostics are available to copy and share with support.
</Text>
</View>
{onPress ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="View crash diagnostics"
onPress={onPress}
style={styles.open}
>
{copy}
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
) : (
copy
)}
{onDismiss ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Dismiss crash diagnostics notice"
hitSlop={spacing.sm}
onPress={onDismiss}
style={styles.dismiss}
>
<X size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>
)
}
@@ -22,7 +58,6 @@ const styles = StyleSheet.create({
alignItems: 'flex-start',
gap: spacing.md,
padding: spacing.md,
marginBottom: spacing.lg,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
@@ -32,6 +67,15 @@ const styles = StyleSheet.create({
flex: 1,
gap: spacing.xs
},
open: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
dismiss: {
padding: spacing.xs
},
title: {
color: colors.textPrimary,
fontSize: typography.bodySize,
@@ -41,5 +85,10 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18
},
actionText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
@@ -0,0 +1,60 @@
import { useCallback, useEffect, useState } from 'react'
import { StyleSheet, View } from 'react-native'
import { useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import {
dismissPreviousMobileCrashSession,
getUndismissedPreviousMobileCrashSession
} from '../diagnostics/mobile-crash-diagnostics'
import type { MobileCrashSessionSnapshot } from '../diagnostics/mobile-crash-session'
import { colors, spacing } from '../theme/mobile-theme'
import { PreviousCrashSessionBanner } from './PreviousCrashSessionBanner'
export function PreviousCrashSessionLaunchNotice() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [previousCrash, setPreviousCrash] = useState<MobileCrashSessionSnapshot | null>(null)
useEffect(() => {
let active = true
void getUndismissedPreviousMobileCrashSession().then((session) => {
if (active) {
setPreviousCrash(session)
}
})
return () => {
active = false
}
}, [])
const dismiss = useCallback(() => {
if (!previousCrash) {
return
}
setPreviousCrash(null)
void dismissPreviousMobileCrashSession(previousCrash.openedAt)
}, [previousCrash])
const openTroubleshooting = useCallback(() => {
dismiss()
router.push('/troubleshoot')
}, [dismiss, router])
if (!previousCrash) {
return null
}
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<PreviousCrashSessionBanner onDismiss={dismiss} onPress={openTroubleshooting} />
</View>
)
}
const styles = StyleSheet.create({
container: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.sm,
backgroundColor: colors.bgBase
}
})
@@ -0,0 +1,73 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
dismiss: vi.fn().mockResolvedValue(undefined),
getUndismissed: vi.fn(),
push: vi.fn()
}))
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 20 })
}))
vi.mock('expo-router', () => ({ useRouter: () => ({ push: mocks.push }) }))
vi.mock('lucide-react-native', () => ({
AlertTriangle: 'AlertTriangle',
ChevronRight: 'ChevronRight',
X: 'X'
}))
vi.mock('../diagnostics/mobile-crash-diagnostics', () => ({
dismissPreviousMobileCrashSession: mocks.dismiss,
getUndismissedPreviousMobileCrashSession: mocks.getUndismissed
}))
import { PreviousCrashSessionLaunchNotice } from './PreviousCrashSessionLaunchNotice'
const previousCrash = {
openedAt: '2026-08-24T18:00:00.000Z',
breadcrumbs: []
}
describe('PreviousCrashSessionLaunchNotice', () => {
beforeEach(() => {
mocks.dismiss.mockClear()
mocks.getUndismissed.mockReset()
mocks.push.mockClear()
})
it('shows an abnormal-session report on launch and opens Troubleshooting', async () => {
mocks.getUndismissed.mockResolvedValue(previousCrash)
let renderer!: ReactTestRenderer
await act(async () => {
renderer = create(createElement(PreviousCrashSessionLaunchNotice))
await Promise.resolve()
})
expect(renderer.root.findByProps({ testID: 'previous-crash-session-banner' })).toBeDefined()
const open = renderer.root.findByProps({ accessibilityLabel: 'View crash diagnostics' })
await act(async () => open.props.onPress())
expect(mocks.dismiss).toHaveBeenCalledWith(previousCrash.openedAt)
expect(mocks.push).toHaveBeenCalledWith('/troubleshoot')
})
it('does not add a launch surface when there is no report', async () => {
mocks.getUndismissed.mockResolvedValue(null)
let renderer!: ReactTestRenderer
await act(async () => {
renderer = create(createElement(PreviousCrashSessionLaunchNotice))
await Promise.resolve()
})
expect(renderer.root.findAllByProps({ testID: 'previous-crash-session-banner' })).toHaveLength(
0
)
})
})
@@ -28,6 +28,14 @@ export function getPreviousMobileCrashSession(): Promise<MobileCrashSessionSnaps
return journal.getLatestAbnormalSession()
}
export function getUndismissedPreviousMobileCrashSession(): Promise<MobileCrashSessionSnapshot | null> {
return journal.getUndismissedLatestAbnormalSession()
}
export function dismissPreviousMobileCrashSession(openedAt: string): Promise<void> {
return journal.dismissLatestAbnormalSession(openedAt)
}
export function buildMobileCrashDiagnosticsReport(): Promise<string> {
return journal.buildReport({
version: Constants.expoConfig?.version ?? 'unknown',
@@ -23,6 +23,7 @@ export type PersistedMobileCrashJournal = {
version: 1
activeSession: PersistedMobileCrashSession
latestAbnormalSession?: MobileCrashSessionSnapshot
dismissedAbnormalSessionOpenedAt?: string
}
export const MOBILE_CRASH_SESSION_STORAGE_KEY = 'orca.mobile-crash-session.v1'
@@ -52,10 +53,15 @@ export function parseMobileCrashJournal(raw: string): PersistedMobileCrashJourna
return null
}
const latestAbnormalSession = parseSnapshot(parsed.latestAbnormalSession)
const dismissedAbnormalSessionOpenedAt =
typeof parsed.dismissedAbnormalSessionOpenedAt === 'string'
? sanitizeCrashReportString(parsed.dismissedAbnormalSessionOpenedAt, 80)
: null
return {
version: 1,
activeSession,
...(latestAbnormalSession ? { latestAbnormalSession } : {})
...(latestAbnormalSession ? { latestAbnormalSession } : {}),
...(dismissedAbnormalSessionOpenedAt ? { dismissedAbnormalSessionOpenedAt } : {})
}
} catch {
return null
@@ -76,6 +82,9 @@ export function serializeMobileCrashJournal(journal: PersistedMobileCrashJournal
breadcrumbs: [...journal.latestAbnormalSession.breadcrumbs]
}
}
: {}),
...(journal.dismissedAbnormalSessionOpenedAt
? { dismissedAbnormalSessionOpenedAt: journal.dismissedAbnormalSessionOpenedAt }
: {})
}
let serialized = JSON.stringify(bounded)
@@ -149,11 +149,50 @@ describe('mobile crash session journal', () => {
})
await expect(second.start()).resolves.toBeNull()
await expect(second.getUndismissedLatestAbnormalSession()).resolves.toBeNull()
await expect(
second.buildReport({ version: '0.0.29', platform: 'android 15' })
).resolves.toContain('No previous abnormal session recorded.')
})
it('keeps a report dismissed across a clean relaunch without hiding the next crash', async () => {
const storage = new MemoryStorage()
const first = new MobileCrashSessionJournal(storage, {
now: () => FIRST_SESSION_AT,
createSessionId: () => 'session-one'
})
await first.start()
const second = new MobileCrashSessionJournal(storage, {
now: () => SECOND_SESSION_AT,
createSessionId: () => 'session-two'
})
const previous = await second.start()
expect(previous).not.toBeNull()
await expect(second.getUndismissedLatestAbnormalSession()).resolves.toEqual(previous)
await second.dismissLatestAbnormalSession(previous?.openedAt ?? '')
await second.recordAppState('background')
const third = new MobileCrashSessionJournal(storage, {
now: () => SECOND_SESSION_AT + 30_000,
createSessionId: () => 'session-three'
})
await third.start()
await expect(third.getUndismissedLatestAbnormalSession()).resolves.toBeNull()
await third.recordRenderError(new Error('a different crash'))
await third.recordAppState('background')
const fourth = new MobileCrashSessionJournal(storage, {
now: () => SECOND_SESSION_AT + 60_000,
createSessionId: () => 'session-four'
})
await fourth.start()
await expect(fourth.getUndismissedLatestAbnormalSession()).resolves.toMatchObject({
openedAt: new Date(SECOND_SESSION_AT + 30_000).toISOString()
})
})
it('reopens the marker when a backgrounded process becomes active again', async () => {
const storage = new MemoryStorage()
const first = new MobileCrashSessionJournal(storage, {
@@ -102,6 +102,9 @@ export class MobileCrashSessionJournal {
activeSession,
...(abnormal || stored?.latestAbnormalSession
? { latestAbnormalSession: abnormal ?? stored?.latestAbnormalSession }
: {}),
...(stored?.dismissedAbnormalSessionOpenedAt
? { dismissedAbnormalSessionOpenedAt: stored.dismissedAbnormalSessionOpenedAt }
: {})
}
await this.persistJournal()
@@ -146,6 +149,27 @@ export class MobileCrashSessionJournal {
return this.enqueue(async () => this.journal?.latestAbnormalSession ?? null)
}
async getUndismissedLatestAbnormalSession(): Promise<MobileCrashSessionSnapshot | null> {
await this.start()
return this.enqueue(async () => {
const latest = this.journal?.latestAbnormalSession
return latest?.openedAt === this.journal?.dismissedAbnormalSessionOpenedAt
? null
: (latest ?? null)
})
}
async dismissLatestAbnormalSession(openedAt: string): Promise<void> {
await this.start()
await this.enqueue(async () => {
if (!this.journal || this.journal.latestAbnormalSession?.openedAt !== openedAt) {
return
}
this.journal.dismissedAbnormalSessionOpenedAt = openedAt
await this.persistJournal()
})
}
async buildReport(app: { version: string; platform: string }): Promise<string> {
await this.start()
return this.enqueue(async () => {