Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@
"@pierre/trees": "1.0.0-beta.4",
"@types/react": "~19.2.0",
"@types/react-dom": "~19.2.3",
"@types/react-test-renderer": "19.1.0",
"babel-preset-expo": "~57.0.9",
"react-test-renderer": "19.2.3",
"tailwindcss": "^4.0.0",
"typescript": "catalog:"
},
Expand Down
54 changes: 48 additions & 6 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import {
createNativeStackScreen,
type NativeStackNavigationOptions,
} from "@react-navigation/native-stack";
import { useEffect, useRef } from "react";
import { useEffect, useRef, type ReactNode } from "react";
import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
import { useResolveClassNames } from "uniwind";

import { AppText as Text } from "./components/AppText";
import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle";
import {
RenderErrorBoundary,
RenderFailureView,
type RenderFailureProps,
} from "./components/RenderErrorBoundary";
import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen";
import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation";
import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen";
Expand Down Expand Up @@ -503,7 +508,7 @@ function NotFoundScreen() {
);
}

export const RootStack = createNativeStackNavigator({
const RootStackConfig = createNativeStackNavigator({
initialRouteName: "Home",
layout: RootStackLayout,
screenOptions: {
Expand Down Expand Up @@ -686,10 +691,15 @@ export const RootStack = createNativeStackNavigator({
// The whole new-task flow (choose project → draft → add project) shares
// draft state via NewTaskFlowProvider. The expo-router era mounted it in
// app/new/_layout.tsx; this layout wrapper is the native-stack equivalent.
layout: ({ children }) => (
<NewTaskFlowProvider>
<View className="flex-1 bg-sheet-solid">{children}</View>
</NewTaskFlowProvider>
// A screen's layout replaces the navigator's screenLayout.
layout: ({ children, route }) => (
<GuardedScreenLayout route={route}>
<NewTaskFlowProvider>
<GuardedScreenLayout route={route}>
<View className="flex-1 bg-sheet-solid">{children}</View>
</GuardedScreenLayout>
</NewTaskFlowProvider>
</GuardedScreenLayout>
),
options: {
gestureEnabled: true,
Expand All @@ -711,6 +721,38 @@ export const RootStack = createNativeStackNavigator({
}),
},
});

function GuardedScreenLayout(props: {
readonly children: ReactNode;
readonly route: { readonly name: string; readonly params?: object | undefined };
}) {
return (
<RenderErrorBoundary
resetKeys={[props.route.params]}
renderFallback={(fallback) => (
<ScreenRenderFallback {...fallback} routeName={props.route.name} />
)}
>
{props.children}
</RenderErrorBoundary>
);
}

function ScreenRenderFallback(props: RenderFailureProps & { readonly routeName: string }) {
const navigation = useNavigation();
const exit = navigation.canGoBack()
? { label: "Go back", onPress: () => navigation.goBack() }
: props.routeName === "Home"
? { label: "Open settings", onPress: () => navigation.navigate("SettingsSheet") }
: { label: "Return home", onPress: () => navigation.dispatch(StackActions.replace("Home")) };

return <RenderFailureView {...props} exit={exit} />;
}

export const RootStack = RootStackConfig.with(function GuardedRootStack({ Navigator }) {
return <Navigator screenLayout={GuardedScreenLayout} />;
});

type RootStackType = typeof RootStack;

const navigationPathConfig = {
Expand Down
94 changes: 94 additions & 0 deletions apps/mobile/src/components/RenderErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useEffect } from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { Pressable, View } from "react-native";
import { describe, expect, it, vi } from "vite-plus/test";

import { RenderErrorBoundary } from "./RenderErrorBoundary";

vi.mock("react-native", () => ({
Pressable: "Pressable",
ScrollView: "ScrollView",
View: "View",
}));
vi.mock("./AppText", () => ({ AppText: "Text" }));
vi.mock("../lib/copyTextWithHaptic", () => ({ copyTextWithHaptic: vi.fn() }));

// React 19's test renderer requires an explicit act environment for updates.
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });

describe("RenderErrorBoundary", () => {
it("contains a failed child and retries without remounting its sibling", async () => {
let throwOnRender = true;
let siblingMounts = 0;
function Feed() {
if (throwOnRender) throw new Error("feed failed");
return <View>feed ready</View>;
}
function Composer() {
useEffect(() => {
siblingMounts += 1;
}, []);
return <View>draft retained</View>;
}
let renderer: ReactTestRenderer | undefined;
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await act(async () => {
renderer = create(
<>
<RenderErrorBoundary
renderFallback={({ retry }) => <Pressable onPress={retry}>Try again</Pressable>}
>
<Feed />
</RenderErrorBoundary>
<Composer />
</>,
);
});
expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1);
expect(renderer?.root.findAllByType(View)).toHaveLength(1);
throwOnRender = false;
await act(async () => {
renderer?.root.findByType(Pressable).props.onPress();
});
expect(renderer?.root.findAllByType(Pressable)).toHaveLength(0);
expect(renderer?.root.findAllByType(View)).toHaveLength(2);
expect(siblingMounts).toBe(1);
} finally {
errorLog.mockRestore();
await act(async () => renderer?.unmount());
}
});

it("clears the failure when route identity changes", async () => {
let throwOnRender = true;
function Screen() {
if (throwOnRender) throw new Error("route failed");
return <View>route ready</View>;
}
const render = (route: string) => (
<RenderErrorBoundary
resetKeys={[route]}
renderFallback={() => <Pressable>Unavailable</Pressable>}
>
<Screen />
</RenderErrorBoundary>
);
let renderer: ReactTestRenderer | undefined;
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
try {
await act(async () => {
renderer = create(render("thread-a"));
});
expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1);
throwOnRender = false;
await act(async () => renderer?.update(render("thread-a")));
expect(renderer?.root.findAllByType(Pressable)).toHaveLength(1);
await act(async () => renderer?.update(render("thread-b")));
expect(renderer?.root.findAllByType(View)).toHaveLength(1);
} finally {
errorLog.mockRestore();
await act(async () => renderer?.unmount());
}
});
});
148 changes: 148 additions & 0 deletions apps/mobile/src/components/RenderErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { Component, type ReactNode } from "react";
import { Pressable, ScrollView, View } from "react-native";

import { AppText as Text } from "./AppText";
import { copyTextWithHaptic } from "../lib/copyTextWithHaptic";

interface RenderErrorBoundaryProps {
readonly children: ReactNode;
readonly resetKeys?: ReadonlyArray<unknown>;
readonly renderFallback?: (props: RenderFailureProps) => ReactNode;
}

interface RenderErrorBoundaryState {
readonly failed: boolean;
readonly error: unknown;
readonly componentStack?: string;
readonly resetKeys?: ReadonlyArray<unknown>;
}

export interface RenderFailureProps {
readonly details: string;
readonly retry: () => void;
}

function errorDetails(error: unknown, componentStack?: string): string {
let description: string;
try {
description = error instanceof Error ? (error.stack ?? error.message) : String(error);
} catch {
description = "Unknown render error";
}
return componentStack ? `${description}\nComponent stack:\n${componentStack}` : description;
}

/** A failed subtree remounts on retry or when its identifying inputs change. */
export class RenderErrorBoundary extends Component<
RenderErrorBoundaryProps,
RenderErrorBoundaryState
> {
override state: RenderErrorBoundaryState = {
failed: false,
error: null,
resetKeys: this.props.resetKeys,
};

static getDerivedStateFromProps(
{ resetKeys }: RenderErrorBoundaryProps,
state: RenderErrorBoundaryState,
): Partial<RenderErrorBoundaryState> | null {
if (
resetKeys?.length !== state.resetKeys?.length ||
resetKeys?.some((key, index) => !Object.is(key, state.resetKeys?.[index]))
) {
return { failed: false, error: null, componentStack: undefined, resetKeys };
}
return null;
}

static getDerivedStateFromError(error: unknown): Partial<RenderErrorBoundaryState> {
return { failed: true, error };
}

override componentDidCatch(_error: unknown, info: { componentStack?: string }) {
this.setState({ componentStack: info.componentStack });
}

private readonly retry = () => {
this.setState({ failed: false, error: null, componentStack: undefined });
};

override render() {
if (!this.state.failed) return this.props.children;
const fallback =
this.props.renderFallback ??
((props: RenderFailureProps) => <RenderFailureView {...props} />);
return fallback({
details: errorDetails(this.state.error, this.state.componentStack),
retry: this.retry,
});
}
}

export function RenderFailureView(
props: RenderFailureProps & {
readonly title?: string;
readonly bottomInset?: number;
readonly exit?: { readonly label: string; readonly onPress: () => void };
},
) {
const title = props.title ?? "This screen couldn't be displayed";
return (
<ScrollView
className="flex-1 bg-screen"
contentContainerClassName="flex-grow items-center justify-center gap-5 px-6 py-8"
contentContainerStyle={
props.bottomInset ? { paddingBottom: 32 + props.bottomInset } : undefined
}
>
<Text accessibilityRole="header" className="text-center text-xl font-t3-bold">
{title}
</Text>
<Text className="text-center text-sm text-foreground-muted">
Try again. If it keeps happening, copy the details for a bug report.
</Text>
<Text selectable className="text-center font-mono text-xs text-danger-foreground">
{props.details.split("\n", 1)[0]?.slice(0, 300)}
</Text>
<View className="w-full max-w-xs gap-2">
<RenderFailureAction label="Try again" onPress={props.retry} primary />
<RenderFailureAction
label="Copy details"
onPress={() => copyTextWithHaptic(props.details, { target: "error details" })}
/>
{props.exit ? (
<RenderFailureAction label={props.exit.label} onPress={props.exit.onPress} />
) : null}
</View>
</ScrollView>
);
}

function RenderFailureAction(props: {
readonly label: string;
readonly onPress: () => void;
readonly primary?: boolean;
}) {
return (
<Pressable
accessibilityRole="button"
className={
props.primary
? "items-center rounded-full bg-primary px-5 py-3 active:opacity-70"
: "items-center rounded-full border border-border bg-secondary px-5 py-3 active:opacity-70"
}
onPress={props.onPress}
>
<Text
className={
props.primary
? "text-sm font-t3-bold text-primary-foreground"
: "text-sm font-t3-bold text-secondary-foreground"
}
>
{props.label}
</Text>
</Pressable>
);
}
Loading
Loading