authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 21:59:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 21:59:58-07:00
log23c7ab901afa66dc8e791a3873b600726a6ba8c7
tree5dda066ac437f3cb10409391cebe006314b3ad5d
parent10882bea739a98f11b942c20c6570eb6126dd010
signature Signed by PGP key B5690EEEBB952194

fix(mobile): recover from screen render errors (#13197)


7 files changed, 281 insertions(+), 55 deletions(-)

apps/mobile/src/Stack.tsx+41-5
......@@ -10,7 +10,7 @@ import {
1010 createNativeStackScreen,
1111 type NativeStackNavigationOptions,
1212} from "@react-navigation/native-stack";
13import { useEffect, useRef } from "react";
13import { useEffect, useRef, type ReactNode } from "react";
1414import {
1515 Platform,
1616 Pressable,
......@@ -23,6 +23,11 @@ import { useResolveClassNames } from "uniwind";
2323
2424import { AppText as Text } from "./components/AppText";
2525import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
26import {
27 RenderErrorBoundary,
28 RenderFailureView,
29 type RenderFailureProps,
30} from "./components/RenderErrorBoundary";
2631import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
2732import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
2833import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
......@@ -777,10 +782,13 @@ const RootStackConfig = createNativeStackNavigator({
777782 // The whole new-task flow (choose project → draft → add project) shares
778783 // draft state via NewTaskFlowProvider. The expo-router era mounted it in
779784 // app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
780 layout: ({ children }) => (
781 <NewTaskFlowProvider>
782 <View className="flex-1 bg-sheet-solid">{children}</View>
783 </NewTaskFlowProvider>
785 // A screen's layout replaces the navigator's screenLayout.
786 layout: ({ children, route }) => (
787 <GuardedScreenLayout route={route}>
788 <NewTaskFlowProvider>
789 <View className="flex-1 bg-sheet-solid">{children}</View>
790 </NewTaskFlowProvider>
791 </GuardedScreenLayout>
784792 ),
785793 options: {
786794 gestureEnabled: true,
......@@ -794,6 +802,33 @@ const RootStackConfig = createNativeStackNavigator({
794802 },
795803});
796804
805function GuardedScreenLayout(props: {
806 readonly children: ReactNode;
807 readonly route: { readonly name: string; readonly params?: object | undefined };
808}) {
809 return (
810 <RenderErrorBoundary
811 resetKeys={[props.route.params]}
812 renderFallback={(fallback) => (
813 <ScreenRenderFallback {...fallback} routeName={props.route.name} />
814 )}
815 >
816 {props.children}
817 </RenderErrorBoundary>
818 );
819}
820
821function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) {
822 const navigation = useNavigation();
823 const exit = navigation.canGoBack()
824 ? { label: "Go back", onPress: () => navigation.goBack() }
825 : props.routeName === "Home"
826 ? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") }
827 : { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) };
828
829 return <RenderFailureView {...props} exit={exit} />;
830}
831
797832export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
798833 const { width, height } = useWindowDimensions();
799834 const usesWorkspaceFlowScreens =
......@@ -801,6 +836,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig
801836
802837 return (
803838 <Navigator
839 screenLayout={GuardedScreenLayout}
804840 screenOptions={({ route }) => {
805841 if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
806842 return {};
apps/mobile/src/components/RenderErrorBoundary.tsx created+127
......@@ -0,0 +1,127 @@
1import { Component, type ReactNode } from "react";
2import { ScrollView, View } from "react-native";
3
4import { AppText as Text } from "./AppText";
5import { MaterialButton } from "./MaterialButton";
6import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";
7
8interface RenderErrorBoundaryProps {
9 readonly children: ReactNode;
10 readonly resetKeys?: ReadonlyArray<unknown>;
11 readonly renderFallback?: (props: RenderFailureProps) => ReactNode;
12}
13
14interface RenderErrorBoundaryState {
15 readonly failed: boolean;
16 readonly error: unknown;
17 readonly componentStack?: string;
18 readonly resetKeys?: ReadonlyArray<unknown>;
19}
20
21export interface RenderFailureProps {
22 readonly details: string;
23 readonly retry: () => void;
24}
25
26function errorDetails(error: unknown, componentStack?: string): string {
27 let description: string;
28 try {
29 description = error instanceof Error ? (error.stack ?? error.message) : String(error);
30 } catch {
31 description = "Unknown render error";
32 }
33 return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description;
34}
35
36/** A failed subtree remounts on retry or when its identifying inputs change. */
37export class RenderErrorBoundary extends Component<
38 RenderErrorBoundaryProps,
39 RenderErrorBoundaryState
40> {
41 override state: RenderErrorBoundaryState = {
42 failed: false,
43 error: null,
44 resetKeys: this.props.resetKeys,
45 };
46
47 static getDerivedStateFromProps(
48 { resetKeys }: RenderErrorBoundaryProps,
49 state: RenderErrorBoundaryState,
50 ): Partial<RenderErrorBoundaryState> | null {
51 if (
52 resetKeys?.length !== state.resetKeys?.length ||
53 resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index]))
54 ) {
55 return { failed: false, error: null, componentStack: undefined, resetKeys };
56 }
57 return null;
58 }
59
60 static getDerivedStateFromError(error: unknown): Partial<RenderErrorBoundaryState> {
61 return { failed: true, error };
62 }
63
64 override componentDidCatch(_error: unknown, info: { componentStack?: string }) {
65 this.setState({ componentStack: info.componentStack });
66 }
67
68 private readonly retry = () => {
69 this.setState({ failed: false, error: null, componentStack: undefined });
70 };
71
72 override render() {
73 if (!this.state.failed) return this.props.children;
74 const fallback =
75 this.props.renderFallback ??
76 ((props: RenderFailureProps) => <RenderFailureView {...props} />);
77 return fallback({
78 details: errorDetails(this.state.error, this.state.componentStack),
79 retry: this.retry,
80 });
81 }
82}
83
84export function RenderFailureView(
85 props: RenderFailureProps & {
86 readonly title?: string;
87 readonly bottomInset?: number;
88 readonly exit?: { readonly label: string; readonly onPress: () => void };
89 },
90) {
91 const title = props.title ?? "This screen couldn't be displayed";
92 return (
93 <ScrollView
94 className="flex-1 bg-screen"
95 contentContainerClassName="flex-grow items-center justify-center gap-5 px-6 py-8"
96 contentContainerStyle={
97 props.bottomInset ? { paddingBottom: 32 + props.bottomInset } : undefined
98 }
99 >
100 <Text accessibilityRole="header" className="text-center text-xl font-t3-bold">
101 {title}
102 </Text>
103 <Text className="text-center text-sm text-foreground-muted">
104 Try again. If it keeps happening, copy the details for a bug report.
105 </Text>
106 <Text selectable className="text-center font-mono text-xs text-danger-foreground">
107 {props.details.split("\n", 1)[0]?.slice(0, 300)}
108 </Text>
109 <View className="w-full max-w-xs gap-2">
110 <MaterialButton label="Try again" onPress={props.retry} tone="primary" fullWidth />
111 <MaterialButton
112 label="Copy details"
113 onPress={() => copyTextWithHaptic(props.details, { target: "error details" })}
114 fullWidth
115 />
116 {props.exit ? (
117 <MaterialButton
118 label={props.exit.label}
119 onPress={props.exit.onPress}
120 tone="text"
121 fullWidth
122 />
123 ) : null}
124 </View>
125 </ScrollView>
126 );
127}
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+27-15
......@@ -57,6 +57,7 @@ import {
5757import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
5858import { HomeListOptionsProvider } from "../home/home-list-options";
5959import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar";
60import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
6061import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
6162import { WorkspaceInspectorPane } from "./workspace-inspector-pane";
6263import { WorkspaceContentWidthContext } from "./workspace-content-width";
......@@ -580,21 +581,31 @@ function AdaptiveWorkspaceLayoutContent(
580581 style={sidebarAnimatedStyle}
581582 >
582583 <View className="flex-1" style={{ width: layout.listPaneWidth }}>
583 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
584 <ThreadNavigationSidebar
585 width={layout.listPaneWidth}
586 visible={panes.primarySidebarVisible}
587 onRequestVisibility={revealPrimarySidebar}
588 selectedThreadKey={selectedThreadKey}
589 onOpenSettings={handleOpenSettings}
590 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
591 onNewThreadInProject={handleNewThreadInProject}
592 onNewThreadOnBranch={handleNewThreadOnBranch}
593 onSelectThread={handleSelectThread}
594 onSearchQueryChange={setPrimarySidebarSearchQuery}
595 searchQuery={primarySidebarSearchQuery}
596 />
597 </AndroidHomeFabLayout>
584 <RenderErrorBoundary
585 renderFallback={(fallback) => (
586 <RenderFailureView
587 {...fallback}
588 title="The sidebar couldn't be displayed"
589 exit={{ label: "Open settings", onPress: handleOpenSettings }}
590 />
591 )}
592 >
593 <AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
594 <ThreadNavigationSidebar
595 width={layout.listPaneWidth}
596 visible={panes.primarySidebarVisible}
597 onRequestVisibility={revealPrimarySidebar}
598 selectedThreadKey={selectedThreadKey}
599 onOpenSettings={handleOpenSettings}
600 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
601 onNewThreadInProject={handleNewThreadInProject}
602 onNewThreadOnBranch={handleNewThreadOnBranch}
603 onSelectThread={handleSelectThread}
604 onSearchQueryChange={setPrimarySidebarSearchQuery}
605 searchQuery={primarySidebarSearchQuery}
606 />
607 </AndroidHomeFabLayout>
608 </RenderErrorBoundary>
598609 </View>
599610 </Animated.View>
600611 ) : null}
......@@ -625,6 +636,7 @@ function AdaptiveWorkspaceLayoutContent(
625636 </View>
626637 </View>
627638 <WorkspaceInspectorPane
639 pathname={props.pathname}
628640 renderedInspectorWidth={renderedInspectorWidth}
629641 active={workspaceInspector?.active ?? false}
630642 panes={panes}
apps/mobile/src/features/layout/workspace-inspector-pane.tsx+16-1
......@@ -8,6 +8,7 @@ import Animated, {
88} from "react-native-reanimated";
99
1010import { constrainAuxiliaryPaneWidth, type WorkspacePaneLayout } from "../../lib/layout";
11import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
1112import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation";
1213import { WorkspacePaneDivider } from "./workspace-pane-divider";
1314
......@@ -23,6 +24,7 @@ import { WorkspacePaneDivider } from "./workspace-pane-divider";
2324 * module stays import-cycle-free with AdaptiveWorkspaceLayout.
2425 */
2526export function WorkspaceInspectorPane(props: {
27 readonly pathname: string;
2628 readonly renderedInspectorWidth: SharedValue<number>;
2729 /**
2830 * When false the pane animates closed but keeps its content mounted for the
......@@ -139,10 +141,23 @@ export function WorkspaceInspectorPane(props: {
139141 style={inspectorStyle}
140142 >
141143 <Animated.View className="flex-1" style={inspectorContentStyle}>
142 {props.renderInspector?.()}
144 <RenderErrorBoundary
145 resetKeys={[props.pathname]}
146 renderFallback={(fallback) => (
147 <RenderFailureView {...fallback} title="The inspector couldn't be displayed" />
148 )}
149 >
150 <InspectorRenderer render={props.renderInspector} />
151 </RenderErrorBoundary>
143152 </Animated.View>
144153 </Animated.View>
145154 ) : null}
146155 </>
147156 );
148157}
158
159// The render callback must run inside the boundary's child, not while its
160// parent constructs the boundary element.
161function InspectorRenderer(props: { readonly render?: () => ReactNode }) {
162 return <>{props.render?.()}</>;
163}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+45-32
......@@ -70,6 +70,7 @@ import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits";
7070import type { ComposerEditorHandle } from "../../components/ComposerEditor";
7171import type { StatusTone } from "../../components/StatusPill";
7272import type { DraftComposerAttachment } from "../../lib/composerImages";
73import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
7374import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
7475import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
7576import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
......@@ -892,39 +893,51 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
892893 : "absolute inset-0 bg-screen"
893894 }
894895 />
895 <ThreadFeed
896 <RenderErrorBoundary
896897 key={selectedThreadKey}
897 environmentId={props.environmentId}
898 threadId={props.selectedThread.id}
899 workspaceRoot={props.threadCwd}
900 feed={props.selectedThreadFeed}
901 worktreeSetup={props.worktreeSetup}
902 setupWorkingStartedAt={props.setupWorkingStartedAt}
903 queuedMessages={props.queuedMessages}
904 dispatchingMessageId={props.dispatchingMessageId}
905 onEditPendingMessage={handleEditPendingMessage}
906 contentPresentation={props.contentPresentation}
907 agentLabel={agentLabel}
908 latestTurn={props.selectedThread.latestTurn}
909 activeWorkStartedAt={props.activeWorkStartedAt}
910 listRef={listRef}
911 freeze={freeze}
912 anchorMessageId={anchorMessageId}
913 submittedMessageId={submittedMessageId}
914 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
915 contentTopInset={0}
916 contentBottomInset={
917 estimatedOverlayHeight + (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
918 }
919 contentMaxWidth={contentMaxWidth}
920 layoutVariant={layoutVariant}
921 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
922 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
923 onEndFollowEnabledChange={setEndFollowEnabled}
924 skills={selectedProviderSkills}
925 onUseArtifactTemplate={handleUseArtifactTemplate}
926 loadEarlier={props.loadEarlier ?? null}
927 />
898 resetKeys={[props.threadCwd]}
899 renderFallback={(fallback) => (
900 <RenderFailureView
901 {...fallback}
902 title="The conversation couldn't be displayed"
903 bottomInset={estimatedOverlayHeight}
904 />
905 )}
906 >
907 <ThreadFeed
908 environmentId={props.environmentId}
909 threadId={props.selectedThread.id}
910 workspaceRoot={props.threadCwd}
911 feed={props.selectedThreadFeed}
912 worktreeSetup={props.worktreeSetup}
913 setupWorkingStartedAt={props.setupWorkingStartedAt}
914 queuedMessages={props.queuedMessages}
915 dispatchingMessageId={props.dispatchingMessageId}
916 onEditPendingMessage={handleEditPendingMessage}
917 contentPresentation={props.contentPresentation}
918 agentLabel={agentLabel}
919 latestTurn={props.selectedThread.latestTurn}
920 activeWorkStartedAt={props.activeWorkStartedAt}
921 listRef={listRef}
922 freeze={freeze}
923 anchorMessageId={anchorMessageId}
924 submittedMessageId={submittedMessageId}
925 contentInsetEndAdjustment={combinedContentInsetEndAdjustment}
926 contentTopInset={0}
927 contentBottomInset={
928 estimatedOverlayHeight +
929 (showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0)
930 }
931 contentMaxWidth={contentMaxWidth}
932 layoutVariant={layoutVariant}
933 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
934 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
935 onEndFollowEnabledChange={setEndFollowEnabled}
936 skills={selectedProviderSkills}
937 onUseArtifactTemplate={handleUseArtifactTemplate}
938 loadEarlier={props.loadEarlier ?? null}
939 />
940 </RenderErrorBoundary>
928941 </View>
929942 ) : (
930943 <View className="flex-1" />
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+10-1
......@@ -606,10 +606,19 @@ function ThreadRouteContent(
606606 Files={FilesInspector}
607607 Git={GitInspector}
608608 mode={inspectorMode}
609 resetKeys={[routeThreadIdentity, selectedThreadCwd]}
609610 Route={props.renderInspector ? RouteInspector : undefined}
610611 />
611612 ),
612 [FilesInspector, GitInspector, RouteInspector, inspectorMode, props.renderInspector],
613 [
614 FilesInspector,
615 GitInspector,
616 RouteInspector,
617 inspectorMode,
618 props.renderInspector,
619 routeThreadIdentity,
620 selectedThreadCwd,
621 ],
613622 );
614623 const activeInspectorRenderer = inspectorMode === null ? undefined : renderInspectorStack;
615624 // Hand the inspector to the workspace so it renders beside the navigator,
apps/mobile/src/features/threads/thread-inspector-content-stack.tsx+15-1
......@@ -1,6 +1,8 @@
11import { useEffect, useState, type ComponentType, type ReactNode } from "react";
22import { View } from "react-native";
33
4import { RenderErrorBoundary, RenderFailureView } from "../../components/RenderErrorBoundary";
5
46export type ThreadInspectorMode = "route" | "git" | "files";
57
68const INSPECTOR_PREWARM_DELAY_MS = 350;
......@@ -8,6 +10,7 @@ const INSPECTOR_PREWARM_DELAY_MS = 350;
810function InspectorContentPane(props: {
911 readonly children: ReactNode;
1012 readonly mounted: boolean;
13 readonly resetKeys: readonly [string | null, string | null];
1114 readonly visible: boolean;
1215}) {
1316 if (!props.mounted) {
......@@ -27,7 +30,14 @@ function InspectorContentPane(props: {
2730 zIndex: props.visible ? 1 : 0,
2831 }}
2932 >
30 {props.children}
33 <RenderErrorBoundary
34 resetKeys={props.resetKeys}
35 renderFallback={(fallback) => (
36 <RenderFailureView {...fallback} title="The inspector couldn't be displayed" />
37 )}
38 >
39 {props.children}
40 </RenderErrorBoundary>
3141 </View>
3242 );
3343}
......@@ -36,6 +46,7 @@ export function ThreadInspectorContentStack(props: {
3646 readonly Files: ComponentType;
3747 readonly Git: ComponentType;
3848 readonly mode: ThreadInspectorMode;
49 readonly resetKeys: readonly [string | null, string | null];
3950 readonly Route?: ComponentType;
4051}) {
4152 const [mountedModes, setMountedModes] = useState<ReadonlySet<ThreadInspectorMode>>(
......@@ -78,12 +89,14 @@ export function ThreadInspectorContentStack(props: {
7889 <View className="flex-1">
7990 <InspectorContentPane
8091 mounted={mountedModes.has("files") || props.mode === "files"}
92 resetKeys={props.resetKeys}
8193 visible={props.mode === "files"}
8294 >
8395 <Files />
8496 </InspectorContentPane>
8597 <InspectorContentPane
8698 mounted={mountedModes.has("git") || props.mode === "git"}
99 resetKeys={props.resetKeys}
87100 visible={props.mode === "git"}
88101 >
89102 <Git />
......@@ -91,6 +104,7 @@ export function ThreadInspectorContentStack(props: {
91104 {Route ? (
92105 <InspectorContentPane
93106 mounted={mountedModes.has("route") || props.mode === "route"}
107 resetKeys={props.resetKeys}
94108 visible={props.mode === "route"}
95109 >
96110 <Route />