diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 3de335cfd562..1aacc8fbecf8 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -10,7 +10,7 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, type ReactNode } from "react";
import {
Platform,
Pressable,
@@ -23,6 +23,8 @@ import { useResolveClassNames } from "uniwind";
import { AppText as Text } from "./components/AppText";
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
+import { RenderErrorBoundary, RenderFailureView } from "./components/RenderErrorBoundary";
+import { screenFallbackExit } from "./components/render-error-boundary-model";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
@@ -760,10 +762,15 @@ const RootStackConfig = 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 `layout` replaces the navigator's default screenLayout, so
+ // this route's boundary lives HERE, wrapping the whole flow (outside the
+ // provider: a provider crash is also caught, and retry remounts it).
+ layout: ({ children, route }) => (
+
+
+ {children}
+
+
),
options: {
gestureEnabled: true,
@@ -777,6 +784,83 @@ const RootStackConfig = createNativeStackNavigator({
},
});
+// NAVIGATION SEAM: every root route renders inside its own error boundary.
+// A crashing screen shows the recovery UI in place — the native header, back
+// gesture, and the rest of the stack stay alive, so recovery works without
+// killing the app. Each route renders this layout within its own screen slot
+// (keyed by the navigator), so a popped route tears its boundary down. Screens
+// nested inside a sheet/stack route share that route's boundary.
+//
+// NOTE: React Navigation resolves the wrapper as `screen.layout ?? group
+// layout ?? navigator screenLayout` (useDescriptors), so any screen that
+// declares its own `layout` BYPASSES this default and must render
+// GuardedScreenLayout inside its own layout (see NewTaskSheet below).
+function GuardedScreenLayout(props: {
+ readonly children: ReactNode;
+ readonly route: { readonly name: string; readonly params?: object | undefined };
+}) {
+ return (
+
+ {props.children}
+
+ );
+}
+
+function ScreenRenderFallback(props: {
+ readonly error: unknown;
+ readonly retry: () => void;
+ readonly componentStack?: string | undefined;
+ readonly routeName?: string | undefined;
+}) {
+ // Screen's per-route context wraps the layout, so this hook resolves to
+ // the guarded root-stack route's own navigation — the same stack the exit
+ // actions (goBack/navigate/replace) need — and it lives outside the
+ // failed subtree, so recovery keeps working when the screen cannot render.
+ const navigation = useNavigation();
+ const exit = screenFallbackExit({
+ canGoBack: navigation.canGoBack(),
+ routeName: props.routeName ?? "",
+ });
+ if (exit === "go-home") {
+ // Replace, not pop: on a single-route cold launch the broken sheet must
+ // unmount, or its fallback would stay on screen behind Home.
+ return (
+ navigation.dispatch(StackActions.replace("Home"))}
+ />
+ );
+ }
+ if (exit === "open-settings") {
+ return (
+ navigation.navigate("SettingsSheet")}
+ />
+ );
+ }
+ return (
+ navigation.goBack()}
+ />
+ );
+}
+
export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
const { width, height } = useWindowDimensions();
const usesWorkspaceFlowScreens =
@@ -784,6 +868,7 @@ export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navig
return (
{
if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
return {};
diff --git a/apps/mobile/src/components/RenderErrorBoundary.tsx b/apps/mobile/src/components/RenderErrorBoundary.tsx
new file mode 100644
index 000000000000..fc2f6cb838f7
--- /dev/null
+++ b/apps/mobile/src/components/RenderErrorBoundary.tsx
@@ -0,0 +1,188 @@
+import { Component, type ComponentType, type ReactNode } from "react";
+import { View } from "react-native";
+
+import { SymbolView } from "./AppSymbol";
+import { AppText as Text } from "./AppText";
+import { MaterialButton } from "./MaterialButton";
+import { tryCopyTextWithHaptic } from "../lib/copyTextWithHaptic";
+import { describeRenderError, readErrorStack, recordRenderError } from "../lib/render-error-log";
+import {
+ boundaryResetFromProps,
+ failedBoundaryState,
+ healthyBoundaryState,
+ type BoundaryState,
+} from "./render-error-boundary-model";
+
+interface RenderErrorBoundaryProps {
+ readonly children: ReactNode;
+ /** Where the error was caught, recorded into the diagnostics render-error log. */
+ readonly scope: string;
+ /** Changed inputs reset a failed subtree without remounting the boundary. */
+ readonly resetKeys?: ReadonlyArray | undefined;
+ /** Subject noun for the default fallback's headline, e.g. "The conversation". */
+ readonly subject?: string;
+ /** Forwarded to a custom `fallback` so it can adapt per route (screen seam). */
+ readonly routeName?: string | undefined;
+ /**
+ * Recovery UI override, rendered as its own component so it can use hooks
+ * (e.g. navigation) even though the boundary itself is a class.
+ */
+ readonly fallback?: ComponentType | undefined;
+}
+
+export interface RenderFallbackProps {
+ readonly error: unknown;
+ readonly retry: () => void;
+ /** React's component stack when the runtime captured one; feeds "Copy details". */
+ readonly componentStack?: string | undefined;
+ /** Route name for screen-seam fallbacks that pick their exit per route. */
+ readonly routeName?: string | undefined;
+}
+
+type RenderErrorBoundaryState = BoundaryState;
+
+/**
+ * Catches render errors in one subtree, records them for diagnostics, and
+ * shows an in-session recovery UI instead of letting the app die. Retrying
+ * unmounts the failed subtree and mounts a fresh one; changed `resetKeys`
+ * (e.g. a thread switch) clear the failure on their own.
+ *
+ * Failure is tracked by a dedicated flag, not the thrown value, so
+ * `throw undefined`/`null`/`""` still render the fallback.
+ *
+ * A caught error never reaches the global fatal handler, so expo-updates'
+ * ErrorRecovery startup log stays exclusively for process-ending fatals —
+ * `recordRenderError` is the sole report path here. Once a boundary recovers,
+ * the throw stops bubbling, so only the innermost boundary records it.
+ */
+export class RenderErrorBoundary extends Component<
+ RenderErrorBoundaryProps,
+ RenderErrorBoundaryState
+> {
+ override state = healthyBoundaryState(this.props.resetKeys);
+
+ // A changed thread/environment underneath a persistent boundary is new input:
+ // retry without waiting for the user to press Try again.
+ static getDerivedStateFromProps(
+ { resetKeys }: RenderErrorBoundaryProps,
+ state: RenderErrorBoundaryState,
+ ) {
+ return boundaryResetFromProps(resetKeys, state);
+ }
+
+ static getDerivedStateFromError(error: unknown) {
+ return failedBoundaryState(error);
+ }
+
+ override componentDidCatch(error: unknown, info: { componentStack?: string }) {
+ recordRenderError(error, this.props.scope, { componentStack: info.componentStack });
+ // Keep the component path for the recovery view's "Copy details" too —
+ // in release builds it may be the only component stack anyone ever sees.
+ if (info.componentStack !== undefined) {
+ this.setState({ componentStack: info.componentStack });
+ }
+ }
+
+ private readonly retry = () => {
+ this.setState(healthyBoundaryState(this.state.resetKeys));
+ };
+
+ override render() {
+ if (this.state.failed) {
+ if (this.props.fallback) {
+ const Fallback = this.props.fallback;
+ return (
+
+ );
+ }
+ return (
+
+ );
+ }
+ return this.props.children;
+ }
+}
+
+/**
+ * The recovery UI itself: retry, copy diagnostics, and (where navigation gives
+ * a way out) go back. It renders without a connection — recovery must work the
+ * same locally and over a tunnel, where the crash itself may have arrived with
+ * remote data.
+ */
+export function RenderFailureView(props: {
+ readonly subject?: string;
+ readonly error: unknown;
+ readonly retry: () => void;
+ readonly componentStack?: string | undefined;
+ readonly onGoBack?: (() => void) | undefined;
+ /** Escape exit for a cold-launch crash where there is no route to go back to. */
+ readonly onOpenSettings?: (() => void) | undefined;
+ /** Escape exit when even Settings is the broken route (replace stack with Home). */
+ readonly onGoHome?: (() => void) | undefined;
+}) {
+ // Safe even for hostile throws (throwing `toString`, primitives, symbols).
+ const message = describeRenderError(props.error);
+ const copy = async () => {
+ // readErrorStack guards hostile stack getters too — copying must never
+ // itself crash the recovery view.
+ const stack = readErrorStack(props.error) ?? message;
+ const detail =
+ props.componentStack !== undefined
+ ? `${stack}\nComponent stack:\n${props.componentStack}`
+ : stack;
+ await tryCopyTextWithHaptic(detail, { target: "render error details" });
+ };
+ return (
+
+
+
+
+ {props.subject ?? "This screen"} couldn’t be displayed
+
+
+ Try again to re-render it. If it keeps happening, copy the details — they help us fix it.
+
+
+ {message.slice(0, 300)}
+
+
+
+
+ void copy()} fullWidth />
+ {props.onGoBack ? (
+
+ ) : null}
+ {props.onOpenSettings ? (
+
+ ) : null}
+ {props.onGoHome ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/render-error-boundary-model.test.ts b/apps/mobile/src/components/render-error-boundary-model.test.ts
new file mode 100644
index 000000000000..d75746b9e458
--- /dev/null
+++ b/apps/mobile/src/components/render-error-boundary-model.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ boundaryResetFromProps,
+ failedBoundaryState,
+ healthyBoundaryState,
+ inspectorResetKeys,
+ screenFallbackExit,
+ threadFeedResetKeys,
+ workspaceInspectorContentIdentity,
+} from "./render-error-boundary-model";
+
+describe("failedBoundaryState", () => {
+ it("marks a failure for falsy throws, which the thrown value alone could not signal", () => {
+ for (const thrown of [undefined, null, "", 0, false]) {
+ const state = failedBoundaryState(thrown);
+ expect(state.failed).toBe(true);
+ expect(state.error).toBe(thrown);
+ }
+ });
+
+ it("omits resetKeys so the setState merge keeps the tracked keys", () => {
+ // Carrying an explicit `resetKeys: undefined` through would look like
+ // changed props on the next render and instantly auto-retry a crash loop.
+ expect("resetKeys" in failedBoundaryState(new Error("boom"))).toBe(false);
+ });
+});
+
+describe("boundaryResetFromProps", () => {
+ it("clears a failure only when the tracked inputs actually changed", () => {
+ const failed = { ...healthyBoundaryState(["thread:1"]), ...failedBoundaryState("boom") };
+ expect(boundaryResetFromProps(["thread:1"], failed)).toBeNull();
+ expect(boundaryResetFromProps(["thread:2"], failed)).toEqual({
+ failed: false,
+ error: undefined,
+ resetKeys: ["thread:2"],
+ });
+ expect(boundaryResetFromProps(["thread:1", "extra"], failed)).not.toBeNull();
+ });
+
+ it("treats unchanged absence of resetKeys as stable, not as a change", () => {
+ const failed = { ...healthyBoundaryState(undefined), ...failedBoundaryState("boom") };
+ expect(boundaryResetFromProps(undefined, failed)).toBeNull();
+ });
+
+ it("retrying a boundary returns to healthy while keeping the tracked keys", () => {
+ const failed = { ...healthyBoundaryState(["thread:1"]), ...failedBoundaryState("boom") };
+ const retried = healthyBoundaryState(failed.resetKeys);
+ expect(retried.failed).toBe(false);
+ expect(boundaryResetFromProps(["thread:1"], retried)).toBeNull();
+ });
+});
+
+describe("inspectorResetKeys", () => {
+ it("does not reset when the same owner rebuilds its render callback", () => {
+ // ThreadRouteScreen rebuilds the inspector callback on unrelated updates
+ // (active-turn churn). Keyed on the callback, a persistently crashing
+ // inspector would reset, re-throw, and re-record on every such update.
+ const renderOne = () => null;
+ const renderTwo = () => null;
+ const [firstKey] = inspectorResetKeys("thread:env1:t1:files", renderOne);
+ const [secondKey] = inspectorResetKeys("thread:env1:t1:files", renderTwo);
+ expect(Object.is(firstKey, secondKey)).toBe(true);
+ });
+
+ it("resets when the inspected content identity changes", () => {
+ const render = () => null;
+ expect(inspectorResetKeys("thread:env1:t1:files", render)).not.toEqual(
+ inspectorResetKeys("thread:env1:t2:files", render),
+ );
+ expect(inspectorResetKeys("thread:env1:t1:git", render)).not.toEqual(
+ inspectorResetKeys("thread:env1:t1:files", render),
+ );
+ });
+
+ it("falls back to the callback when no identity is registered", () => {
+ const render = () => null;
+ expect(inspectorResetKeys(undefined, render)).toEqual([render]);
+ });
+
+ it("resets a crashed review inspector on new section, thread, or worktree", () => {
+ const render = () => null;
+ const crashed = inspectorResetKeys(
+ workspaceInspectorContentIdentity({
+ source: "review",
+ workspaceKey: "env1|t1",
+ cwd: "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/wt/a",
+ contentId: "section-a",
+ }),
+ render,
+ );
+ // Same content, rebuilt callback: still no reset (covered above).
+ // New section, same section id in another thread, or a moved worktree
+ // are all new content and must clear the crashed fallback.
+ const switched = [{ contentId: "section-b" }, { workspaceKey: "env1|t2" }, { cwd: "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/wt/b" }];
+ for (const change of switched) {
+ expect(
+ inspectorResetKeys(
+ workspaceInspectorContentIdentity({
+ source: "review",
+ workspaceKey: "env1|t1",
+ cwd: "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/wt/a",
+ contentId: "section-a",
+ ...change,
+ }),
+ render,
+ ),
+ ).not.toEqual(crashed);
+ }
+ });
+
+ it("resets a crashed files inspector when the worktree moves under a stable thread", () => {
+ const render = () => null;
+ const base = {
+ source: "files" as const,
+ workspaceKey: "env1|t1",
+ cwd: "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/wt/a",
+ contentId: "src/a.ts",
+ };
+ const crashed = inspectorResetKeys(workspaceInspectorContentIdentity(base), render);
+ // threadId present but cwd changed: the audited collision.
+ expect(
+ inspectorResetKeys(workspaceInspectorContentIdentity({ ...base, cwd: "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/wt/b" }), render),
+ ).not.toEqual(crashed);
+ expect(
+ inspectorResetKeys(
+ workspaceInspectorContentIdentity({ ...base, workspaceKey: "env1|t2" }),
+ render,
+ ),
+ ).not.toEqual(crashed);
+ // Same content → same identity: unrelated callback rebuilds still do not reset.
+ expect(inspectorResetKeys(workspaceInspectorContentIdentity({ ...base }), render)).toEqual(
+ crashed,
+ );
+ });
+
+ it("resets a crashed thread inspector when the inspected cwd changes", () => {
+ const render = () => null;
+ const base = {
+ source: "thread" as const,
+ workspaceKey: "env1|t1",
+ cwd: "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/wt/a",
+ contentId: "files",
+ };
+ const crashed = inspectorResetKeys(workspaceInspectorContentIdentity(base), render);
+ // Same thread and mode, worktree moved: workspace-bound content changed.
+ expect(
+ inspectorResetKeys(workspaceInspectorContentIdentity({ ...base, cwd: "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/wt/b" }), render),
+ ).not.toEqual(crashed);
+ // Sources stay distinct even with otherwise identical parts.
+ expect(
+ inspectorResetKeys(workspaceInspectorContentIdentity({ ...base, source: "files" }), render),
+ ).not.toEqual(crashed);
+ });
+ it("cannot collide on delimiter-joined parts", () => {
+ const render = () => null;
+ const a = workspaceInspectorContentIdentity({
+ source: "files",
+ workspaceKey: "env1|t1",
+ cwd: "/repo:a",
+ contentId: "b",
+ });
+ const b = workspaceInspectorContentIdentity({
+ source: "files",
+ workspaceKey: "env1|t1",
+ cwd: "/repo",
+ contentId: "a:b",
+ });
+ expect(a).not.toBe(b);
+ // Absent and the literal string "none" are different facts.
+ expect(
+ workspaceInspectorContentIdentity({
+ source: "review",
+ workspaceKey: "w",
+ cwd: null,
+ contentId: null,
+ }),
+ ).not.toBe(
+ workspaceInspectorContentIdentity({
+ source: "review",
+ workspaceKey: "w",
+ cwd: null,
+ contentId: "none",
+ }),
+ );
+ expect(inspectorResetKeys(a, render)).not.toEqual(inspectorResetKeys(b, render));
+ });
+});
+
+describe("threadFeedResetKeys", () => {
+ it("treats a same-thread worktree move as new content", () => {
+ expect(threadFeedResetKeys("env1|t1", "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/wt/a")).not.toEqual(
+ threadFeedResetKeys("env1|t1", "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/wt/b"),
+ );
+ expect(threadFeedResetKeys("env1|t1", "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/wt/a")).toEqual(
+ threadFeedResetKeys("env1|t1", "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/wt/a"),
+ );
+ expect(threadFeedResetKeys("env1|t1", null)).toEqual(threadFeedResetKeys("env1|t1", undefined));
+ });
+});
+
+describe("screenFallbackExit", () => {
+ it("offers Go back when a previous route exists", () => {
+ expect(screenFallbackExit({ canGoBack: true, routeName: "SettingsSheet" })).toBe("go-back");
+ expect(screenFallbackExit({ canGoBack: true, routeName: "Home" })).toBe("go-back");
+ });
+
+ it("offers Settings when the crashing route is the only route", () => {
+ // Cold launch straight into a broken Home: no back gesture, and the
+ // Settings sheet is outside the failed subtree and always reachable.
+ expect(screenFallbackExit({ canGoBack: false, routeName: "Home" })).toBe("open-settings");
+ });
+
+ it("offers Home when the Settings sheet itself is the cold-launch crash", () => {
+ // "Open settings" on a broken, already-focused SettingsSheet navigates to
+ // the broken route — the exit must replace the stack with Home instead.
+ expect(screenFallbackExit({ canGoBack: false, routeName: "SettingsSheet" })).toBe("go-home");
+ });
+});
diff --git a/apps/mobile/src/components/render-error-boundary-model.ts b/apps/mobile/src/components/render-error-boundary-model.ts
new file mode 100644
index 000000000000..0957f3b0e713
--- /dev/null
+++ b/apps/mobile/src/components/render-error-boundary-model.ts
@@ -0,0 +1,123 @@
+/**
+ * Pure state model for `RenderErrorBoundary`, kept separate from the RN view
+ * so the failure bookkeeping is directly testable.
+ *
+ * The failure signal is a dedicated `failed` flag, never the thrown value:
+ * `throw undefined` / `throw null` / `throw ""` must still fail the boundary,
+ * so the value itself can't double as the sentinel.
+ */
+export interface BoundaryState {
+ readonly failed: boolean;
+ readonly error: unknown;
+ /** React's component stack, when the runtime provides one; shown on copy. */
+ readonly componentStack?: string | undefined;
+ readonly resetKeys?: ReadonlyArray | undefined;
+}
+
+/** Returned by getDerivedStateFromError; omitting other keys keeps them through the setState merge. */
+export function failedBoundaryState(error: unknown): Pick {
+ return { failed: true, error };
+}
+
+export function healthyBoundaryState(
+ resetKeys?: ReadonlyArray | undefined,
+): BoundaryState {
+ return { failed: false, error: undefined, componentStack: undefined, resetKeys };
+}
+
+/**
+ * `getDerivedStateFromProps` logic: changed `resetKeys` (e.g. a thread switch
+ * under a persistent boundary) clear a failure without waiting for user
+ * action; unchanged keys leave the current state untouched.
+ */
+export function boundaryResetFromProps(
+ resetKeys: ReadonlyArray | undefined,
+ state: BoundaryState,
+): BoundaryState | null {
+ if (
+ resetKeys?.length !== state.resetKeys?.length ||
+ resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index]))
+ ) {
+ return healthyBoundaryState(resetKeys);
+ }
+ return null;
+}
+
+/**
+ * Reset keys for the thread feed boundary. The feed renders entries AND the
+ * worktree setup card for a cwd, so a same-thread worktree move (cwd change
+ * under a stable thread key) is new content and must clear a stale failure.
+ */
+export function threadFeedResetKeys(
+ threadKey: string,
+ cwd: string | null | undefined,
+): ReadonlyArray {
+ return [threadKey, cwd ?? null];
+}
+
+/**
+ * Which exit the screen-level fallback offers: normally Go back, but when the
+ * crashing route is the only route (cold launch on Home), there is no previous
+ * route and no back gesture — the fallback must still lead somewhere that can
+ * show the recorded diagnostics, so it offers Settings instead. When the
+ * broken route IS the Settings sheet itself, navigating to it would land back
+ * on the crash, so the only safe exit is replacing the stack with Home.
+ */
+export type ScreenFallbackExit = "go-back" | "open-settings" | "go-home";
+
+export function screenFallbackExit(args: {
+ readonly canGoBack: boolean;
+ readonly routeName: string;
+}): ScreenFallbackExit {
+ if (args.canGoBack) return "go-back";
+ if (args.routeName === "SettingsSheet") return "go-home";
+ return "open-settings";
+}
+
+/**
+ * Reset signature for the inspector boundary. The registrant's stable
+ * content identity (owner + content the user perceives, e.g. route thread +
+ * inspector mode) wins over the render callback: registrants like
+ * ThreadRouteScreen rebuild the callback on unrelated updates (active-turn
+ * churn), so keying on it would reset — re-throw, and re-record — a
+ * persistently crashing inspector on every such update, spamming the bounded
+ * diagnostics log. Without an identity there is nothing better than the
+ * callback itself.
+ */
+export function inspectorResetKeys(
+ contentIdentity: string | undefined,
+ render: (() => unknown) | undefined,
+): ReadonlyArray {
+ return contentIdentity !== undefined ? [contentIdentity] : [render];
+}
+
+/**
+ * Identity builder for the known registrants. The rule each encodes: the
+ * identity changes exactly when the content the user perceives changes —
+ * selecting a healthy section out of a crashed inspector must reset, while
+ * unrelated route state churn must not.
+ *
+ * Every inspector's content is workspace-bound (a diff, a file tree, a thread
+ * view), so all three parts ride the key: the route/thread the content
+ * belongs to, the cwd it renders (a thread's worktree can move, and
+ * same-id content recurs across workspaces), and the content selection
+ * itself. Keying on any subset lets a crashed fallback persist over new,
+ * healthy content.
+ */
+export function workspaceInspectorContentIdentity(args: {
+ readonly source: "thread" | "review" | "files";
+ readonly workspaceKey: string | null | undefined;
+ readonly cwd: string | null | undefined;
+ readonly contentId: string | null | undefined;
+}): string {
+ // JSON tuple, not ':'-joined: paths and ids contain ':' and users can
+ // legitimately have a content id of "none", so delimiter joining (and
+ // null-mapping to "none") can collide — and a collision re-arms the
+ // failed-over-inspector bug this identity exists to prevent.
+ return JSON.stringify([
+ args.source,
+ args.workspaceKey ?? null,
+ args.cwd ?? null,
+ args.contentId ?? null,
+ ]);
+}
diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx
index 3d4a79d85910..adc8a3bbe375 100644
--- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx
+++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx
@@ -1,7 +1,7 @@
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import Constants from "expo-constants";
import * as Updates from "expo-updates";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useSyncExternalStore } from "react";
import { ActivityIndicator, Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -16,6 +16,12 @@ import {
parseStartupCrashRecords,
type StartupCrashRecord,
} from "./crash-log-model";
+import {
+ formatRenderErrorReport,
+ getRenderErrorRecords,
+ subscribeToRenderErrors,
+ type RenderErrorRecord,
+} from "../../lib/render-error-log";
// expo-updates keeps its persistent log this long. Reading any further back
// returns nothing, so this is the whole available window.
@@ -47,6 +53,13 @@ export function SettingsDiagnosticsRouteScreen() {
Updates.isEnabled ? { status: "loading" } : { status: "unavailable" },
);
const [copied, setCopied] = useState(false);
+ const [renderCopied, setRenderCopied] = useState(false);
+ // Session memory, not persisted. Subscribed because another root route can
+ // crash (and record) while this screen stays mounted, e.g. in split view.
+ const renderRecords = useSyncExternalStore>(
+ subscribeToRenderErrors,
+ getRenderErrorRecords,
+ );
useEffect(() => {
if (!Updates.isEnabled) return;
@@ -72,6 +85,12 @@ export function SettingsDiagnosticsRouteScreen() {
});
if (ok) setCopied(true);
};
+ const copyRenderReport = async () => {
+ const ok = await tryCopyTextWithHaptic(formatRenderErrorReport(renderRecords, appIdentity()), {
+ target: "render error report",
+ });
+ if (ok) setRenderCopied(true);
+ };
return (
@@ -107,6 +126,20 @@ export function SettingsDiagnosticsRouteScreen() {
)}
+
+ {renderRecords.length === 0 ? (
+
+ ) : (
+ renderRecords.map((record, index) => (
+
+ ))
+ )}
+
+
void copyReport()}
/>
+ void copyRenderReport()}
+ />
Paste the report into a GitHub issue. It contains the app version, the JavaScript error
@@ -147,6 +186,20 @@ function EmptyState(props: {
);
}
+function RenderErrorRow(props: { readonly record: RenderErrorRecord; readonly first: boolean }) {
+ const { record } = props;
+ return (
+
+
+ {new Date(record.timestamp).toLocaleString()} · {record.scope}
+
+
+ {record.message}
+
+
+ );
+}
+
function CrashRow(props: { readonly record: StartupCrashRecord; readonly first: boolean }) {
const { record } = props;
return (
diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
index 06ef8f6ba4d9..cac3ab14a690 100644
--- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
+++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
@@ -1,5 +1,7 @@
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import { workspaceInspectorContentIdentity } from "../../components/render-error-boundary-model";
+import { scopedThreadKey } from "../../lib/scopedEntities";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -723,7 +725,20 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) {
() => renderInspector(inspectorHeaderInset),
[inspectorHeaderInset, renderInspector],
);
- useRegisterWorkspaceInspector(fileInspector.supported ? renderWorkspaceInspector : undefined);
+ useRegisterWorkspaceInspector(
+ fileInspector.supported ? renderWorkspaceInspector : undefined,
+ // Thread and cwd are BOTH part of the key: a thread's inspected
+ // worktree can move (cwd change) while the thread id stays the same.
+ workspaceInspectorContentIdentity({
+ source: "files",
+ workspaceKey:
+ environmentId !== null && threadId !== null
+ ? scopedThreadKey(environmentId, threadId)
+ : null,
+ cwd,
+ contentId: relativePath,
+ }),
+ );
const fileMenuActions = useMemo(() => {
if (relativePath === null) return [];
diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
index 6c6133c7ef13..934a2a4635b8 100644
--- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
+++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx
@@ -31,6 +31,8 @@ import Animated, {
} from "react-native-reanimated";
import { AsyncResult } from "effect/unstable/reactivity";
+import { RenderErrorBoundary } from "../../components/RenderErrorBoundary";
+
import {
deriveFileInspectorPaneLayout,
deriveLayout,
@@ -76,7 +78,10 @@ interface AdaptiveWorkspaceContextValue {
* registration already took over — stale deactivates never clobber it.
* Prefer useRegisterWorkspaceInspector over calling this directly.
*/
- readonly registerWorkspaceInspector: (render: () => ReactNode) => () => void;
+ readonly registerWorkspaceInspector: (
+ render: () => ReactNode,
+ identity?: string | undefined,
+ ) => () => void;
readonly setPrimarySidebarSearchQuery: (query: string) => void;
readonly showAuxiliaryPane: (role: WorkspaceAuxiliaryPaneRole) => void;
readonly toggleAuxiliaryPane: () => void;
@@ -138,7 +143,10 @@ export function useAdaptiveWorkspacePaneRole(role: WorkspaceAuxiliaryPaneRole) {
* animates closed, or is replaced seamlessly when the next route registers in
* the same commit); focus re-registers it.
*/
-export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefined) {
+export function useRegisterWorkspaceInspector(
+ render: (() => ReactNode) | undefined,
+ identity?: string | undefined,
+) {
const { registerWorkspaceInspector } = useAdaptiveWorkspaceLayout();
// Raw context values (not the useNavigation/useRoute wrappers) so the
// portal re-provides exactly what this screen sees.
@@ -166,8 +174,8 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi
deactivateRef.current?.();
return;
}
- deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current);
- }, [registerWorkspaceInspector]);
+ deactivateRef.current = registerWorkspaceInspector(wrappedRenderRef.current, identity);
+ }, [identity, registerWorkspaceInspector]);
// Focus lifecycle. Blur/focus events fire even when the blurred subtree is
// frozen (events are navigation-driven, renders are not).
@@ -182,7 +190,9 @@ export function useRegisterWorkspaceInspector(render: (() => ReactNode) | undefi
}, [syncRegistration]),
);
- // Content changes while focused re-register in place.
+ // Content changes while focused re-register in place; identity rides the
+ // same syncRegistration change so the workspace's stored identity stays
+ // current even if a registrant changes it without changing the callback.
useEffect(() => {
if (focusedRef.current) {
syncRegistration();
@@ -315,23 +325,30 @@ function AdaptiveWorkspaceLayoutContent(
// seamlessly by the next route's registration in the same commit).
const [workspaceInspector, setWorkspaceInspector] = useState<{
readonly render: () => ReactNode;
+ /** Registrant-provided stable content identity (see inspectorResetKeys). */
+ readonly identity: string | undefined;
readonly active: boolean;
} | null>(null);
const workspaceInspectorOwner = useRef(null);
- const registerWorkspaceInspector = useCallback((render: () => ReactNode) => {
- const owner = Symbol("workspace-inspector");
- workspaceInspectorOwner.current = owner;
- setWorkspaceInspector({ render, active: true });
+ const registerWorkspaceInspector = useCallback(
+ (render: () => ReactNode, identity?: string | undefined) => {
+ const owner = Symbol("workspace-inspector");
+ workspaceInspectorOwner.current = owner;
+ setWorkspaceInspector({ render, identity, active: true });
- return () => {
- // During a push/replace the outgoing screen deactivates AFTER the
- // incoming screen registered — only the current owner may deactivate.
- if (workspaceInspectorOwner.current !== owner) {
- return;
- }
- setWorkspaceInspector((current) => (current === null ? null : { ...current, active: false }));
- };
- }, []);
+ return () => {
+ // During a push/replace the outgoing screen deactivates AFTER the
+ // incoming screen registered — only the current owner may deactivate.
+ if (workspaceInspectorOwner.current !== owner) {
+ return;
+ }
+ setWorkspaceInspector((current) =>
+ current === null ? null : { ...current, active: false },
+ );
+ };
+ },
+ [],
+ );
// Once the close animation settles, drop the stale content entirely.
const handleWorkspaceInspectorClosed = useCallback(() => {
setWorkspaceInspector((current) => (current !== null && !current.active ? null : current));
@@ -580,21 +597,28 @@ function AdaptiveWorkspaceLayoutContent(
style={sidebarAnimatedStyle}
>
-
-
-
+ {/* The sidebar and inspector render OUTSIDE the navigator's
+ screen slots (the workspace layout wraps the whole stack),
+ so no screenLayout boundary covers them. Scope their own
+ failures here: a broken sidebar must not take the open
+ thread (or the app) down, and vice versa. */}
+
+
+
+
+
) : null}
@@ -627,6 +651,7 @@ function AdaptiveWorkspaceLayoutContent(
ReactNode) | undefined }) {
+ return props.render?.() ?? null;
+}
+
export function WorkspaceInspectorPane(props: {
readonly renderedInspectorWidth: SharedValue;
/**
@@ -33,6 +46,8 @@ export function WorkspaceInspectorPane(props: {
readonly onClosed?: () => void;
readonly panes: WorkspacePaneLayout;
readonly renderInspector?: () => ReactNode;
+ /** Stable content identity from the registrant; resets the boundary only when the inspected content really changes. */
+ readonly inspectorIdentity?: string | undefined;
readonly setAuxiliaryPaneWidth: (width: number) => void;
}) {
const { panes, setAuxiliaryPaneWidth } = props;
@@ -139,7 +154,19 @@ export function WorkspaceInspectorPane(props: {
style={inspectorStyle}
>
- {props.renderInspector?.()}
+ {/* INSIDE the pane so a content crash swaps only the content: the
+ fixed-width column, reveal animation, and resize divider stay
+ mounted and a fallback never becomes a flex sibling of the
+ column. The renderer identity is the content input — a new
+ inspector (e.g. after a route change) must not inherit the
+ previous renderer's failure. */}
+
+
+
) : null}
diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx
index 2fac5376e5b1..85acdf65db37 100644
--- a/apps/mobile/src/features/review/ReviewSheet.tsx
+++ b/apps/mobile/src/features/review/ReviewSheet.tsx
@@ -1,5 +1,6 @@
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import { workspaceInspectorContentIdentity } from "../../components/render-error-boundary-model";
import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
import { ScreenHeader } from "../../components/ScreenHeader";
import type { ScreenHeaderMenuItem } from "../../components/ScreenHeader.types";
@@ -672,7 +673,21 @@ export function ReviewSheet(props: ReviewSheetProps) {
selectedSection !== null &&
parsedDiff.kind === "files" &&
NativeReviewDiffView !== null;
- useRegisterWorkspaceInspector(showChangedFilesPane ? renderInspector : undefined);
+ useRegisterWorkspaceInspector(
+ showChangedFilesPane ? renderInspector : undefined,
+ // Workspace-scoped per-section identity: the same section id can recur
+ // across threads/worktrees, and a thread's worktree cwd can move, so the
+ // thread key and cwd ride the key. Selecting new healthy content (new
+ // section, new cwd) resets; unrelated rebuilds do not.
+ showChangedFilesPane
+ ? workspaceInspectorContentIdentity({
+ source: "review",
+ workspaceKey: reviewCache.threadKey,
+ cwd: selectedThreadCwd,
+ contentId: selectedSection?.id,
+ })
+ : undefined,
+ );
// A toggle needs registered content; loading, errors and raw patches have no navigator pane.
const showChangedFilesToggle = panes.supportsAuxiliaryPane && showChangedFilesPane;
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index ad0872ea9116..01757977b790 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -72,6 +72,8 @@ import type { StatusTone } from "../../components/StatusPill";
import type { DraftComposerAttachment } from "../../lib/composerImages";
import { CHAT_CONTENT_MAX_WIDTH, type LayoutVariant } from "../../lib/layout";
import { IOS_NAV_BAR_HEIGHT } from "../../lib/layoutMetrics";
+import { RenderErrorBoundary } from "../../components/RenderErrorBoundary";
+import { threadFeedResetKeys } from "../../components/render-error-boundary-model";
import { editPendingThreadMessage } from "../../state/edit-pending-thread-message";
import { deviceEnvironment } from "../../state/device";
import { useEnvironmentQuery } from "../../state/query";
@@ -892,39 +894,49 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
: "absolute inset-0 bg-screen"
}
/>
-
+ {/* A crash while rendering feed entries is scoped here: the composer,
+ header, and navigation survive, and switching threads (new
+ resetKeys) clears the failure without any user action. */}
+
+
+
) : (
diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
index e5bfc1590cf0..4848571e36d0 100644
--- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@@ -48,6 +48,7 @@ import { vcsEnvironment } from "../../state/vcs";
import { EmptyState } from "../../components/EmptyState";
import { LoadingScreen } from "../../components/LoadingScreen";
import { scopedThreadKey } from "../../lib/scopedEntities";
+import { workspaceInspectorContentIdentity } from "../../components/render-error-boundary-model";
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
import { connectionTone } from "../connection/connectionTone";
import {
@@ -615,7 +616,26 @@ function ThreadRouteContent(
// Hand the inspector to the workspace so it renders beside the navigator,
// outside this screen's native header — the terminal/git/files toolbar
// stays anchored to the chat pane instead of floating above the inspector.
- useRegisterWorkspaceInspector(activeInspectorRenderer);
+ // Stable content identity for the inspector boundary: the thread the pane
+ // actually shows plus its mode. Callback identity churns with active-turn
+ // updates (see renderInspectorStack) and must not drive the reset.
+ useRegisterWorkspaceInspector(
+ activeInspectorRenderer,
+ // Thread key + cwd + mode: the Files/Git inspectors render the thread's
+ // current worktree, and the cwd can move under a stable thread id, so a
+ // crashed inspector must reset when the workspace it shows changes.
+ activeInspectorRenderer === undefined
+ ? undefined
+ : workspaceInspectorContentIdentity({
+ source: "thread",
+ workspaceKey:
+ selectedThread === null
+ ? null
+ : scopedThreadKey(selectedThread.environmentId, selectedThread.id),
+ cwd: selectedThreadCwd,
+ contentId: inspectorMode,
+ }),
+ );
const handleOpenConnectionEditor = useCallback(() => {
void navigation.navigate("Connections");
diff --git a/apps/mobile/src/lib/render-error-log.test.ts b/apps/mobile/src/lib/render-error-log.test.ts
new file mode 100644
index 000000000000..004c46fa1638
--- /dev/null
+++ b/apps/mobile/src/lib/render-error-log.test.ts
@@ -0,0 +1,217 @@
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+
+import {
+ clearRenderErrorRecords,
+ describeRenderError,
+ formatRenderErrorReport,
+ getRenderErrorRecords,
+ readErrorStack,
+ recordRenderError,
+ subscribeToRenderErrors,
+} from "./render-error-log";
+
+beforeEach(() => {
+ clearRenderErrorRecords();
+});
+
+describe("recordRenderError", () => {
+ it("records the message, stack, scope, and timestamp, newest first", () => {
+ const first = new Error("first broke");
+ const second = new Error("second broke");
+ recordRenderError(first, "thread-feed", { timestamp: 100 });
+ recordRenderError(second, "screen:Thread", { timestamp: 200 });
+
+ const records = getRenderErrorRecords();
+ expect(records.map((record) => record.message)).toEqual(["second broke", "first broke"]);
+ expect(records[0]?.scope).toBe("screen:Thread");
+ expect(records[1]?.detail).toContain("first broke");
+ expect(records[1]?.detail).toContain("Error: first broke");
+ });
+
+ it("records a cached error again when a retry re-throws the same object", () => {
+ // A module-level or memoized Error keeps its identity across re-throws.
+ // Identity-based dedupe would silently swallow every crash after the
+ // first, which is exactly the report the retry most needs.
+ const cached = new Error("deterministically broken");
+ recordRenderError(cached, "thread-feed", { timestamp: 100 });
+ recordRenderError(cached, "thread-feed", { timestamp: 200 });
+ const records = getRenderErrorRecords();
+ expect(records).toHaveLength(2);
+ expect(records[0]?.timestamp).toBe(200);
+ });
+
+ it("keeps same-millisecond catches distinct for list identity", () => {
+ recordRenderError(new Error("a"), "screen:Thread", { timestamp: 100 });
+ recordRenderError(new Error("b"), "screen:Thread", { timestamp: 100 });
+ const [newest, oldest] = getRenderErrorRecords();
+ expect(newest?.id).not.toBe(oldest?.id);
+ });
+
+ it("keeps the newest records when the log overflows", () => {
+ for (let index = 0; index < 25; index += 1) {
+ recordRenderError(new Error(`error ${index}`), "screen:Home", { timestamp: index });
+ }
+ const records = getRenderErrorRecords();
+ expect(records).toHaveLength(20);
+ expect(records[0]?.message).toBe("error 24");
+ expect(records[19]?.message).toBe("error 5");
+ });
+
+ it("appends the component stack when the runtime provides one", () => {
+ recordRenderError(new Error("bad render"), "thread-feed", {
+ componentStack: "\n in ThreadFeed\n in View",
+ });
+ expect(getRenderErrorRecords()[0]?.detail).toContain("Component stack:\n in ThreadFeed");
+ });
+});
+
+describe("describeRenderError", () => {
+ it("falls back to the error name when the message is empty", () => {
+ expect(describeRenderError(new TypeError(""))).toBe("TypeError");
+ });
+
+ it("stringifies non-error throws", () => {
+ expect(describeRenderError("boom")).toBe("boom");
+ const thrown = { toString: () => "custom" };
+ expect(describeRenderError(thrown)).toBe("custom");
+ });
+
+ it("survives hostile throws whose toString rethrows", () => {
+ // Reporting and the recovery view must never become the second crash.
+ const thrown = {
+ toString() {
+ throw new Error("nope");
+ },
+ };
+ expect(() => describeRenderError(thrown)).not.toThrow();
+ expect(describeRenderError(thrown)).toBe("[object Object]");
+ });
+
+ it("falls back to a constant when even the fallback stringify rethrows", () => {
+ const hostile = {
+ toString() {
+ throw new Error("nope");
+ },
+ get [Symbol.toStringTag]() {
+ throw new Error("also nope");
+ },
+ };
+ expect(() => describeRenderError(hostile)).not.toThrow();
+ expect(describeRenderError(hostile)).toBe("[unstringifiable value]");
+ });
+
+ it("survives Error objects whose message/name/stack getters throw", () => {
+ // All three are read inside componentDidCatch's record path; a throw from
+ // any of them would defeat the recovery it is part of.
+ const hostile = new Error("base");
+ const explode = () => {
+ throw new Error("getter");
+ };
+ Object.defineProperty(hostile, "message", { get: explode });
+ Object.defineProperty(hostile, "name", { get: explode });
+ Object.defineProperty(hostile, "stack", { get: explode });
+
+ expect(() => recordRenderError(hostile, "thread-feed")).not.toThrow();
+ expect(() => readErrorStack(hostile)).not.toThrow();
+ expect(readErrorStack(hostile)).toBeUndefined();
+ const record = getRenderErrorRecords()[0];
+ expect(record?.message).toBe("Error");
+ expect(record?.detail).toBe("Error");
+ });
+
+ it("survives throws where even instanceof Error rethrows", () => {
+ // instanceof walks the prototype chain; a Proxy with a throwing
+ // getPrototypeOf trap must not break the record path either.
+ const hostile = new Proxy(
+ {},
+ {
+ getPrototypeOf() {
+ throw new TypeError("no proto");
+ },
+ },
+ );
+ expect(() => describeRenderError(hostile)).not.toThrow();
+ expect(() => recordRenderError(hostile, "screen:Thread")).not.toThrow();
+ expect(() => readErrorStack(hostile)).not.toThrow();
+ expect(readErrorStack(hostile)).toBeUndefined();
+ expect(getRenderErrorRecords()[0]?.message).toBe("[object Object]");
+ });
+
+ it("keeps ordinary Error fields when the getters behave", () => {
+ const error = new Error("plain failure");
+ expect(describeRenderError(error)).toBe("plain failure");
+ expect(readErrorStack(error)).toContain("plain failure");
+ });
+});
+
+describe("subscribeToRenderErrors", () => {
+ it("notifies listeners and hands them a new snapshot on every write", () => {
+ const before = getRenderErrorRecords();
+ let notified = 0;
+ const unsubscribe = subscribeToRenderErrors(() => {
+ notified += 1;
+ });
+
+ recordRenderError(new Error("crash while diagnostics is open"), "screen:Thread");
+ expect(notified).toBe(1);
+ const after = getRenderErrorRecords();
+ expect(after).not.toBe(before);
+ expect(after[0]?.message).toBe("crash while diagnostics is open");
+
+ unsubscribe();
+ recordRenderError(new Error("after unsubscribe"), "screen:Thread");
+ expect(notified).toBe(1);
+ });
+
+ it("notifies on clear so a mounted view cannot show stale rows", () => {
+ recordRenderError(new Error("something"), "screen:Thread");
+ let notified = 0;
+ const unsubscribe = subscribeToRenderErrors(() => {
+ notified += 1;
+ });
+ clearRenderErrorRecords();
+ expect(notified).toBe(1);
+ expect(getRenderErrorRecords()).toHaveLength(0);
+ unsubscribe();
+ });
+
+ it("isolates a throwing subscriber on both notify paths", () => {
+ // recordRenderError runs inside componentDidCatch; a throwing subscriber
+ // must not propagate through the catch, and must not starve the
+ // subscribers registered after it.
+ let healthyNotified = 0;
+ const unsubscribeBad = subscribeToRenderErrors(() => {
+ throw new Error("subscriber exploded");
+ });
+ const unsubscribeGood = subscribeToRenderErrors(() => {
+ healthyNotified += 1;
+ });
+
+ expect(() => recordRenderError(new Error("crash"), "screen:Thread")).not.toThrow();
+ expect(healthyNotified).toBe(1);
+ expect(() => clearRenderErrorRecords()).not.toThrow();
+ expect(healthyNotified).toBe(2);
+
+ unsubscribeBad();
+ unsubscribeGood();
+ });
+});
+
+describe("formatRenderErrorReport", () => {
+ it("labels an empty session without pretending a crash happened", () => {
+ const report = formatRenderErrorReport([], { version: "1.2.3", build: "45" });
+ expect(report).toContain("T3 Code 1.2.3 (45)");
+ expect(report).toContain("No recovered render errors this session.");
+ });
+
+ it("renders one section per record with scope and detail", () => {
+ recordRenderError(new Error("feed exploded"), "thread-feed", { timestamp: 1789277752000 });
+ const report = formatRenderErrorReport(getRenderErrorRecords(), {
+ version: "1.2.3",
+ build: "45",
+ });
+ expect(report).toContain("recovered render errors");
+ expect(report).toContain("2026-09-13T05:35:52.000Z [thread-feed]");
+ expect(report).toContain("feed exploded");
+ });
+});
diff --git a/apps/mobile/src/lib/render-error-log.ts b/apps/mobile/src/lib/render-error-log.ts
new file mode 100644
index 000000000000..051801b06576
--- /dev/null
+++ b/apps/mobile/src/lib/render-error-log.ts
@@ -0,0 +1,162 @@
+/**
+ * In-memory log of render errors the app caught and recovered from during the
+ * current session.
+ *
+ * This deliberately does NOT feed the expo-updates crash log the Diagnostics
+ * screen reads: `crash-log-model.ts` captures only ErrorRecovery fatals that
+ * took the process down at startup. A caught render error never reaches the
+ * global fatal handler, so it could never appear there — recording it here is
+ * the only way to surface it, and routing it anywhere else would double-report
+ * what the startup log already owns.
+ *
+ * There is no dedupe by error identity on purpose: a cached error re-thrown on
+ * retry is a fresh incident worth recording again, and the same throw seen by
+ * nested boundaries leaves one row per scope, which reads as the bubble path.
+ */
+export interface RenderErrorRecord {
+ /** Unique per recorded catch; two catches in the same millisecond stay distinct. */
+ readonly id: number;
+ readonly timestamp: number;
+ /** Where it was caught, e.g. `screen:Thread` or `thread-feed`. */
+ readonly scope: string;
+ readonly message: string;
+ /** Message + stack (+ component stack when the runtime provides one). */
+ readonly detail: string;
+}
+
+const MAX_RECORDS = 20;
+let records: RenderErrorRecord[] = [];
+let nextRecordId = 1;
+const listeners = new Set<() => void>();
+
+// recordRenderError runs inside componentDidCatch: one broken subscriber must
+// not replace the recovery path mid-catch, nor starve the subscribers after
+// it (useSyncExternalStore listeners must all see the change).
+function notifyRenderErrorListeners(): void {
+ for (const listener of listeners) {
+ try {
+ listener();
+ } catch {
+ // The log's own write already succeeded; a subscriber's render error
+ // will surface through its own boundary, not through this stack.
+ }
+ }
+}
+
+/**
+ * `useSyncExternalStore` pair. The records array is replaced (never mutated)
+ * on every write, so `getRenderErrorRecords` is a stable snapshot getter: the
+ * Diagnostics screen sees a crash recorded by another route while it is open.
+ */
+export function subscribeToRenderErrors(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function describeRenderError(error: unknown): string {
+ if (isErrorLike(error)) {
+ // message/name/stack can be throwing getters on hostile or exotic errors,
+ // and this runs inside componentDidCatch — a throw here would defeat the
+ // recovery it is part of, so every property read is guarded.
+ const message = readSafely(() => error.message);
+ if (typeof message === "string" && message.length > 0) return message;
+ const name = readSafely(() => error.name);
+ if (typeof name === "string" && name.length > 0) return name;
+ return "Error";
+ }
+ return safeString(error);
+}
+
+/** The error's own stack, or undefined when absent or unreadable. */
+export function readErrorStack(error: unknown): string | undefined {
+ if (!isErrorLike(error)) return undefined;
+ const stack = readSafely(() => error.stack);
+ return typeof stack === "string" ? stack : undefined;
+}
+
+// instanceof consults the prototype chain (and Symbol.hasInstance), so even
+// the type check must be guarded against exotic throws (e.g. a Proxy with a
+// throwing getPrototypeOf trap) — this runs inside componentDidCatch.
+function isErrorLike(error: unknown): error is Error {
+ return readSafely(() => error instanceof Error) ?? false;
+}
+
+function readSafely(read: () => T): T | undefined {
+ try {
+ return read();
+ } catch {
+ return undefined;
+ }
+}
+
+// A hostile `toString`/`Symbol.toPrimitive`/`Symbol.toStringTag` must not turn
+// error reporting — or the recovery view that renders the message — into a
+// second crash, so even the fallback stringify is guarded.
+function safeString(value: unknown): string {
+ try {
+ return String(value);
+ } catch {
+ try {
+ return Object.prototype.toString.call(value);
+ } catch {
+ return "[unstringifiable value]";
+ }
+ }
+}
+
+/**
+ * Record a caught render error. Every boundary that catches a throw records it
+ * against its own scope, so one crash can produce one row per scope it bubbled
+ * through — a useful trail, and the opposite of suppressing a cached error
+ * that legitimately throws again after a retry.
+ */
+export function recordRenderError(
+ error: unknown,
+ scope: string,
+ options: { readonly componentStack?: string | undefined; readonly timestamp?: number } = {},
+): void {
+ const message = describeRenderError(error);
+ const stack = readErrorStack(error);
+ const detail = [
+ message,
+ stack !== undefined ? stack : "",
+ options.componentStack !== undefined && options.componentStack !== null
+ ? `Component stack:${options.componentStack}`
+ : "",
+ ]
+ .filter((part) => part.length > 0)
+ .join("\n");
+ records = [
+ { id: nextRecordId++, timestamp: options.timestamp ?? Date.now(), scope, message, detail },
+ ...records,
+ ].slice(0, MAX_RECORDS);
+ notifyRenderErrorListeners();
+}
+
+/** Newest first, as stored. */
+export function getRenderErrorRecords(): ReadonlyArray {
+ return records;
+}
+
+export function clearRenderErrorRecords(): void {
+ records = [];
+ notifyRenderErrorListeners();
+}
+
+/** The report a user pastes into an issue, mirroring the startup crash report. */
+export function formatRenderErrorReport(
+ input: ReadonlyArray,
+ app: { readonly version: string; readonly build: string },
+): string {
+ const header = `T3 Code ${app.version} (${app.build}) — recovered render errors`;
+ if (input.length === 0) return `${header}\nNo recovered render errors this session.`;
+ return [
+ header,
+ ...input.map(
+ (record) =>
+ `\n--- ${new Date(record.timestamp).toISOString()} [${record.scope}] ---\n${record.detail}`,
+ ),
+ ].join("\n");
+}