Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bf13cdf
fix(mobile): recover from render errors in-place with scoped boundaries
juliusmarminge Sep 22, 2026
d330f4f
fix(mobile): track render failure by flag and exit cold-launch crashes
juliusmarminge Sep 22, 2026
6babe9a
fix(mobile): close boundary coverage gaps found in review
juliusmarminge Sep 22, 2026
e5bdb01
fix(mobile): harden recovery view and cold-launch exit per review
juliusmarminge Sep 22, 2026
21c3d34
fix(mobile): live-subscribe the Diagnostics render-error list
juliusmarminge Sep 22, 2026
a4e849c
fix(mobile): scope the inspector boundary inside the pane and harden …
juliusmarminge Sep 22, 2026
aeb3582
fix(mobile): guard every hostile read path and catch inspector-callba…
juliusmarminge Sep 22, 2026
3e01a27
fix(mobile): stabilize inspector reset identity and finish hostile-th…
juliusmarminge Sep 22, 2026
401d3c7
fix(mobile): content-accurate inspector identities and isolated subsc…
juliusmarminge Sep 22, 2026
d2c8618
fix(mobile): make every inspector identity fully workspace-bound
juliusmarminge Sep 22, 2026
2232d2d
docs(mobile): correct the seam fallback's navigation comment
juliusmarminge Sep 22, 2026
333b8fe
fix(mobile): keep never-painted Home crashes fatal and de-collide res…
juliusmarminge Sep 23, 2026
5144e3e
fix(mobile): throw the cold-launch Home failure inside the failed ren…
juliusmarminge Sep 23, 2026
cb480a0
docs(mobile): state the expo-updates startup-error pipeline exactly
juliusmarminge Sep 23, 2026
fd62e5e
fix(mobile): disarm the first-paint valve once any frame has painted
juliusmarminge Sep 23, 2026
796bbd7
refactor(mobile): drop the Home first-paint fatal valve — it could ne…
juliusmarminge Sep 23, 2026
f213db0
refactor(mobile): move the render-error log from features/diagnostics…
juliusmarminge Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 90 additions & 5 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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 }) => (
<NewTaskFlowProvider>
<View className="flex-1 bg-sheet-solid">{children}</View>
</NewTaskFlowProvider>
// 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 }) => (
<GuardedScreenLayout route={route}>
<NewTaskFlowProvider>
<View className="flex-1 bg-sheet-solid">{children}</View>
</NewTaskFlowProvider>
</GuardedScreenLayout>
),
options: {
gestureEnabled: true,
Expand All @@ -777,13 +784,91 @@ 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 (
<RenderErrorBoundary
scope={`screen:${props.route.name}`}
routeName={props.route.name}
// In split view the Thread route stays mounted while a sidebar selection
// swaps its params; new params are new input and must not inherit a
// previous thread's failure state.
resetKeys={[props.route.params]}
fallback={ScreenRenderFallback}
>
{props.children}
</RenderErrorBoundary>
);
}

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 (
<RenderFailureView
error={props.error}
retry={props.retry}
componentStack={props.componentStack}
onGoHome={() => navigation.dispatch(StackActions.replace("Home"))}
/>
);
}
if (exit === "open-settings") {
return (
<RenderFailureView
error={props.error}
retry={props.retry}
componentStack={props.componentStack}
onOpenSettings={() => navigation.navigate("SettingsSheet")}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
/>
);
}
return (
<RenderFailureView
error={props.error}
retry={props.retry}
componentStack={props.componentStack}
onGoBack={() => navigation.goBack()}
/>
);
}

export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) {
const { width, height } = useWindowDimensions();
const usesWorkspaceFlowScreens =
Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView;

return (
<Navigator
screenLayout={GuardedScreenLayout}
screenOptions={({ route }) => {
if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") {
return {};
Expand Down
188 changes: 188 additions & 0 deletions apps/mobile/src/components/RenderErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown> | 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<RenderFallbackProps> | 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 (
<Fallback
error={this.state.error}
retry={this.retry}
componentStack={this.state.componentStack}
routeName={this.props.routeName}
/>
);
}
return (
<RenderFailureView
subject={this.props.subject}
error={this.state.error}
retry={this.retry}
componentStack={this.state.componentStack}
/>
);
}
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 (
<View className="flex-1 items-center justify-center gap-4 px-8 py-10">
<SymbolView
name="exclamationmark.triangle"
size={30}
tintColorClassName="accent-icon"
type="monochrome"
weight="regular"
/>
<View className="items-center gap-2">
<Text className="text-center text-xl font-t3-bold text-foreground">
{props.subject ?? "This screen"} couldn’t be displayed
</Text>
<Text className="text-center font-sans text-sm leading-relaxed text-foreground-muted">
Try again to re-render it. If it keeps happening, copy the details — they help us fix it.
</Text>
<Text
selectable
className="text-center font-mono text-xs leading-snug text-danger-foreground"
>
{message.slice(0, 300)}
</Text>
</View>
<View className="w-full max-w-xs items-stretch gap-2">
<MaterialButton label="Try again" onPress={props.retry} tone="primary" fullWidth />
<MaterialButton label="Copy details" onPress={() => void copy()} fullWidth />
{props.onGoBack ? (
<MaterialButton label="Go back" onPress={props.onGoBack} tone="text" fullWidth />
) : null}
{props.onOpenSettings ? (
<MaterialButton
label="Open settings"
onPress={props.onOpenSettings}
tone="text"
fullWidth
/>
) : null}
{props.onGoHome ? (
<MaterialButton label="Return home" onPress={props.onGoHome} tone="text" fullWidth />
) : null}
</View>
</View>
);
}
Loading
Loading