diff --git a/apps/mobile/package.json b/apps/mobile/package.json index df96bfa182..69256cbdba 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -131,7 +131,9 @@ "@pierre/trees": "1.0.0-beta.4", "@types/react": "~19.2.0", "@types/react-dom": "~19.2.3", + "@types/react-test-renderer": "19.1.0", "babel-preset-expo": "~57.0.9", + "react-test-renderer": "19.2.3", "tailwindcss": "^4.0.0", "typescript": "catalog:" }, diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 22c02e9957..9f2b81b6b7 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,12 +10,17 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; +import { + RenderErrorBoundary, + RenderFailureView, + type RenderFailureProps, +} from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; @@ -503,7 +508,7 @@ function NotFoundScreen() { ); } -export const RootStack = createNativeStackNavigator({ +const RootStackConfig = createNativeStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, screenOptions: { @@ -686,10 +691,15 @@ export const RootStack = createNativeStackNavigator({ // The whole new-task flow (choose project → draft → add project) shares // draft state via NewTaskFlowProvider. The expo-router era mounted it in // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent. - layout: ({ children }) => ( - - {children} - + // A screen's layout replaces the navigator's screenLayout. + layout: ({ children, route }) => ( + + + + {children} + + + ), options: { gestureEnabled: true, @@ -711,6 +721,38 @@ export const RootStack = createNativeStackNavigator({ }), }, }); + +function GuardedScreenLayout(props: { + readonly children: ReactNode; + readonly route: { readonly name: string; readonly params?: object | undefined }; +}) { + return ( + ( + + )} + > + {props.children} + + ); +} + +function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) { + const navigation = useNavigation(); + const exit = navigation.canGoBack() + ? { label: "Go back", onPress: () => navigation.goBack() } + : props.routeName === "Home" + ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") } + : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) }; + + return ; +} + +export const RootStack = RootStackConfig.with(function GuardedRootStack({ Navigator }) { + return ; +}); + type RootStackType = typeof RootStack; const navigationPathConfig = { diff --git a/apps/mobile/src/components/RenderErrorBoundary.test.tsx b/apps/mobile/src/components/RenderErrorBoundary.test.tsx new file mode 100644 index 0000000000..a2e17e4655 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.test.tsx @@ -0,0 +1,94 @@ +import { useEffect } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { Pressable, View } from "react-native"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { RenderErrorBoundary } from "./RenderErrorBoundary"; + +vi.mock("react-native", () => ({ + Pressable: "Pressable", + ScrollView: "ScrollView", + View: "View", +})); +vi.mock("./AppText", () => ({ AppText: "Text" })); +vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() })); + +// React 19's test renderer requires an explicit act environment for updates. +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +describe("RenderErrorBoundary", () => { + it("contains a failed child and retries without remounting its sibling", async () => { + let throwOnRender = true; + let siblingMounts = 0; + function Feed() { + if (throwOnRender) throw new Error("feed failed"); + return feed ready; + } + function Composer() { + useEffect(() => { + siblingMounts += 1; + }, []); + return draft retained; + } + let renderer: ReactTestRenderer | undefined; + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await act(async () => { + renderer = create( + <> + Try again} + > + + + + , + ); + }); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1); + expect(renderer?.root.findAllByType(View)).toHaveLength(1); + throwOnRender = false; + await act(async () => { + renderer?.root.findByType(Pressable).props.onPress(); + }); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(0); + expect(renderer?.root.findAllByType(View)).toHaveLength(2); + expect(siblingMounts).toBe(1); + } finally { + errorLog.mockRestore(); + await act(async () => renderer?.unmount()); + } + }); + + it("clears the failure when route identity changes", async () => { + let throwOnRender = true; + function Screen() { + if (throwOnRender) throw new Error("route failed"); + return route ready; + } + const render = (route: string) => ( + Unavailable} + > + + + ); + let renderer: ReactTestRenderer | undefined; + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + await act(async () => { + renderer = create(render("thread-a")); + }); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1); + throwOnRender = false; + await act(async () => renderer?.update(render("thread-a"))); + expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1); + await act(async () => renderer?.update(render("thread-b"))); + expect(renderer?.root.findAllByType(View)).toHaveLength(1); + } finally { + errorLog.mockRestore(); + await act(async () => renderer?.unmount()); + } + }); +}); diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx new file mode 100644 index 0000000000..e670f3b5d7 --- /dev/null +++ b/apps/mobile/src/components/RenderErrorBoundary.tsx @@ -0,0 +1,148 @@ +import { Component, type ReactNode } from "react"; +import { Pressable, ScrollView, View } from "react-native"; + +import { AppText as Text } from "./AppText"; +import { copyTextWithHaptic } from "../lib/copyTextWithHaptic"; + +interface RenderErrorBoundaryProps { + readonly children: ReactNode; + readonly resetKeys?: ReadonlyArray; + readonly renderFallback?: (props: RenderFailureProps) => ReactNode; +} + +interface RenderErrorBoundaryState { + readonly failed: boolean; + readonly error: unknown; + readonly componentStack?: string; + readonly resetKeys?: ReadonlyArray; +} + +export interface RenderFailureProps { + readonly details: string; + readonly retry: () => void; +} + +function errorDetails(error: unknown, componentStack?: string): string { + let description: string; + try { + description = error instanceof Error ? (error.stack ?? error.message) : String(error); + } catch { + description = "Unknown render error"; + } + return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description; +} + +/** A failed subtree remounts on retry or when its identifying inputs change. */ +export class RenderErrorBoundary extends Component< + RenderErrorBoundaryProps, + RenderErrorBoundaryState +> { + override state: RenderErrorBoundaryState = { + failed: false, + error: null, + resetKeys: this.props.resetKeys, + }; + + static getDerivedStateFromProps( + { resetKeys }: RenderErrorBoundaryProps, + state: RenderErrorBoundaryState, + ): Partial | null { + if ( + resetKeys?.length !== state.resetKeys?.length || + resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index])) + ) { + return { failed: false, error: null, componentStack: undefined, resetKeys }; + } + return null; + } + + static getDerivedStateFromError(error: unknown): Partial { + return { failed: true, error }; + } + + override componentDidCatch(_error: unknown, info: { componentStack?: string }) { + this.setState({ componentStack: info.componentStack }); + } + + private readonly retry = () => { + this.setState({ failed: false, error: null, componentStack: undefined }); + }; + + override render() { + if (!this.state.failed) return this.props.children; + const fallback = + this.props.renderFallback ?? + ((props: RenderFailureProps) => ); + return fallback({ + details: errorDetails(this.state.error, this.state.componentStack), + retry: this.retry, + }); + } +} + +export function RenderFailureView( + props: RenderFailureProps & { + readonly title?: string; + readonly bottomInset?: number; + readonly exit?: { readonly label: string; readonly onPress: () => void }; + }, +) { + const title = props.title ?? "This screen couldn't be displayed"; + return ( + + + {title} + + + Try again. If it keeps happening, copy the details for a bug report. + + + {props.details.split("\n", 1)[0]?.slice(0, 300)} + + + + copyTextWithHaptic(props.details, { target: "error details" })} + /> + {props.exit ? ( + + ) : null} + + + ); +} + +function RenderFailureAction(props: { + readonly label: string; + readonly onPress: () => void; + readonly primary?: boolean; +}) { + return ( + + + {props.label} + + + ); +} diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index c030e49c2b..93d8ad9a1f 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -54,6 +54,7 @@ import { AndroidHomeFabLayout } from "../home/AndroidHomeFab"; import { HomeListOptionsProvider } from "../home/home-list-options"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; import { WorkspaceContentWidthContext } from "./workspace-content-width"; @@ -561,21 +562,31 @@ function AdaptiveWorkspaceLayoutContent( style={sidebarAnimatedStyle} > - - - + ( + + )} + > + + + + ) : null} @@ -601,6 +612,7 @@ function AdaptiveWorkspaceLayoutContent( ; /** * When false the pane animates closed but keeps its content mounted for the @@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: { style={inspectorStyle} > - {props.renderInspector?.()} + ( + + )} + > + + ) : null} ); } + +// The render callback must run inside the boundary's child, not while its +// parent constructs the boundary element. +function InspectorRenderer(props: { readonly render?: () => ReactNode }) { + return <>{props.render?.()}; +} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index ad5c6c4a52..9c37a20fe8 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -89,6 +89,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout"; import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics"; import { editPendingThreadMessage } from "../../state/edit-pending-thread-message"; @@ -991,43 +992,55 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread : "absolute inset-0 bg-screen" } /> - + resetKeys={[props.threadCwd]} + renderFallback={(fallback) => ( + + )} + > + + ) : ( diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 729430f1f7..3cd8b9e975 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -544,10 +544,19 @@ function ThreadRouteContent( Files={FilesInspector} Git={GitInspector} mode={inspectorMode} + resetKeys={[routeThreadIdentity, selectedThreadCwd]} Route={props.renderInspector ? RouteInspector : undefined} /> ), - [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector], + [ + FilesInspector, + GitInspector, + RouteInspector, + inspectorMode, + props.renderInspector, + routeThreadIdentity, + selectedThreadCwd, + ], ); const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack; // Hand the inspector to the workspace so it renders beside the navigator, diff --git a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx index 9b41ed4e36..5a1601b0e5 100644 --- a/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx +++ b/apps/mobile/src/features/threads/thread-inspector-content-stack.tsx @@ -1,6 +1,8 @@ import { useEffect, useState, type ComponentType, type ReactNode } from "react"; import { View } from "react-native"; +import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary"; + export type ThreadInspectorMode = "route" | "git" | "files"; const INSPECTOR_PREWARM_DELAY_MS = 350; @@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350; function InspectorContentPane(props: { readonly children: ReactNode; readonly mounted: boolean; + readonly resetKeys: readonly [string | null, string | null]; readonly visible: boolean; }) { if (!props.mounted) { @@ -27,7 +30,14 @@ function InspectorContentPane(props: { zIndex: props.visible ? 1 : 0, }} > - {props.children} + ( + + )} + > + {props.children} + ); } @@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: { readonly Files: ComponentType; readonly Git: ComponentType; readonly mode: ThreadInspectorMode; + readonly resetKeys: readonly [string | null, string | null]; readonly Route?: ComponentType; }) { const [mountedModes, setMountedModes] = useState>( @@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: { @@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: { {Route ? ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbdf753057..7c1cbcf06b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -490,9 +490,15 @@ importers: '@types/react-dom': specifier: ~19.2.3 version: 19.2.3(@types/react@19.2.16) + '@types/react-test-renderer': + specifier: 19.1.0 + version: 19.1.0 babel-preset-expo: specifier: ~57.0.9 version: 57.0.9(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@57.0.15)(expo@57.0.18)(react-refresh@0.14.2) + react-test-renderer: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) tailwindcss: specifier: 4.3.3 version: 4.3.3 @@ -10009,6 +10015,11 @@ packages: '@types/react': optional: true + react-test-renderer@19.2.3: + resolution: {integrity: sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==} + peerDependencies: + react: ^19.2.3 + react-test-renderer@19.2.6: resolution: {integrity: sha512-GbS6V23YduFTPiWJ5xICbKEjRcqx1Z90js/V5miqhz7qp/d6xSe9Dd6NjSQODFRdzdsqRMPW82E/sFpPRbY5Mw==} peerDependencies: @@ -21524,6 +21535,12 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + react-test-renderer@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + react-is: 19.2.7 + scheduler: 0.27.0 + react-test-renderer@19.2.6(react@19.2.6): dependencies: react: 19.2.6