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"); +}