diff --git a/docs/expo-router.md b/docs/expo-router.md
index 4ccdc14d39..6ff9d14c9c 100644
--- a/docs/expo-router.md
+++ b/docs/expo-router.md
@@ -47,6 +47,15 @@ state.
This split is deliberate. The host layout must mount first so native local
dynamic params exist before any nested workspace leaf is selected.
+## Error Recovery
+
+The root error boundary lives above `ExpoRoot` in `src/root-app.tsx`. Reload
+remounts the router at `/open-project`, preserving saved workspace layouts and
+the remembered selection. Do not move the boundary inside the root layout:
+catching a render error there unmounts the navigator, so the recovery button
+cannot reliably dispatch a navigation reset. Starting recovery at `/` would
+restore the same workspace and could repeat the crash.
+
## App-Wide Route Hops
When app-wide routes such as `/new`, `/settings`, or `/sessions` navigate back
diff --git a/packages/app/e2e/browser/root-error-recovery.spec.ts b/packages/app/e2e/browser/root-error-recovery.spec.ts
new file mode 100644
index 0000000000..26fc70c4bb
--- /dev/null
+++ b/packages/app/e2e/browser/root-error-recovery.spec.ts
@@ -0,0 +1,96 @@
+import { createServer, type Server } from "node:http";
+import { expect, test, type Page, type TestInfo } from "@playwright/test";
+
+let server: Server;
+let appUrl: string;
+test.beforeAll(async () => {
+ const metroPort = process.env.E2E_METRO_PORT;
+ if (!metroPort) throw new Error("E2E_METRO_PORT is required");
+ const bundle = `http://localhost:${metroPort}/packages/app/e2e/support/recovery-app.bundle?platform=web&dev=true&minify=false&hot=false&lazy=true&transform.engine=hermes&transform.routerRoot=src%2Fapp`;
+ server = createServer((_request, response) => {
+ response.setHeader("Content-Type", "text/html");
+ response.end(
+ `
`,
+ );
+ });
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("Fixture server did not start");
+ appUrl = `http://127.0.0.1:${address.port}`;
+});
+
+test.afterAll(async () => {
+ await new Promise((resolve, reject) =>
+ server.close((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ }),
+ );
+});
+
+async function openBrokenStartup(page: Page) {
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "workspace-layout-state",
+ JSON.stringify({ state: { layoutByWorkspace: {} }, version: 1 }),
+ );
+ localStorage.setItem(
+ "paseo:last-workspace-route-selection",
+ JSON.stringify({ serverId: "fixture-host", workspaceId: "broken" }),
+ );
+ });
+ await page.goto(appUrl);
+ await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible();
+}
+async function readSavedWorkspaceState(page: Page) {
+ return page.evaluate(() => ({
+ layout: localStorage.getItem("workspace-layout-state"),
+ selection: localStorage.getItem("paseo:last-workspace-route-selection"),
+ }));
+}
+async function reloadToPicker(page: Page) {
+ await page.getByRole("button", { name: "Reload", exact: true }).click();
+ await expect(page).toHaveURL(`${appUrl}/open-project`);
+ await expect(page.getByText("Project picker", { exact: true })).toBeVisible();
+ await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toHaveCount(0);
+}
+async function openHealthyWorkspace(page: Page) {
+ await page.getByRole("link", { name: "Open healthy workspace" }).click();
+ await expect(page.getByText("Healthy workspace", { exact: true })).toBeVisible();
+}
+async function breakCurrentWorkspace(page: Page) {
+ await page.getByRole("button", { name: "Break this workspace" }).click();
+ await expect(page.getByText("Paseo ran into a problem.", { exact: true })).toBeVisible();
+}
+async function captureRecoveryScreen(page: Page, testInfo: TestInfo, name: string) {
+ const path = testInfo.outputPath(`${name}.png`);
+ await page.screenshot({ path });
+ await testInfo.attach(name, { path, contentType: "image/png" });
+}
+
+test("Reload escapes a startup redirect and supports another recovery", async ({
+ page,
+}, testInfo) => {
+ await openBrokenStartup(page);
+ const saved = await readSavedWorkspaceState(page);
+ await captureRecoveryScreen(page, testInfo, "error-screen");
+ await reloadToPicker(page);
+ expect(await readSavedWorkspaceState(page)).toEqual(saved);
+ await captureRecoveryScreen(page, testInfo, "recovered-picker");
+ await openHealthyWorkspace(page);
+ await breakCurrentWorkspace(page);
+ await reloadToPicker(page);
+});
+
+test.describe("compact error recovery", () => {
+ test.use({ viewport: { width: 390, height: 844 } });
+ test("Reload remains reachable and returns to the picker", async ({ page }, testInfo) => {
+ await openBrokenStartup(page);
+ await captureRecoveryScreen(page, testInfo, "compact-error-screen");
+ await reloadToPicker(page);
+ await captureRecoveryScreen(page, testInfo, "compact-recovered-picker");
+ });
+});
diff --git a/packages/app/e2e/support/recovery-app.tsx b/packages/app/e2e/support/recovery-app.tsx
new file mode 100644
index 0000000000..4ec02edd26
--- /dev/null
+++ b/packages/app/e2e/support/recovery-app.tsx
@@ -0,0 +1,55 @@
+// Test-only entry: the production recovery boundary and Expo router with failing routes.
+import "../../src/styles/unistyles";
+// oxlint-disable-next-line import/no-unassigned-import -- Match the Metro web entry runtime.
+import "@expo/metro-runtime";
+import { registerRootComponent } from "expo";
+import { Link, Redirect, Stack, type Href } from "expo-router";
+import { useCallback, useState, type ComponentProps } from "react";
+import { Button, Text, View } from "react-native";
+import { RootRouter } from "../../src/root-app";
+
+const screenOptions = { headerShown: false };
+function Layout() {
+ return ;
+}
+// Fixture routes are absent from Expo's generated production route types.
+function Startup() {
+ return ;
+}
+function BrokenWorkspace(): never {
+ throw new Error("Workspace recovery fixture");
+}
+function Picker() {
+ return (
+
+ Project picker
+ Open healthy workspace
+
+ );
+}
+function HealthyWorkspace() {
+ const [broken, setBroken] = useState(false);
+ const breakWorkspace = useCallback(() => setBroken(true), []);
+ if (broken) return ;
+ return (
+
+ Healthy workspace
+
+
+ );
+}
+const screens = {
+ "./_layout.tsx": { default: Layout },
+ "./index.tsx": { default: Startup },
+ "./broken.tsx": { default: BrokenWorkspace },
+ "./open-project.tsx": { default: Picker },
+ "./healthy.tsx": { default: HealthyWorkspace },
+};
+const context: ComponentProps["context"] = Object.assign(
+ (key: string) => screens[key as keyof typeof screens],
+ { keys: () => Object.keys(screens), resolve: (key: string) => key, id: "recovery-fixture" },
+);
+function RecoveryApp() {
+ return ;
+}
+registerRootComponent(RecoveryApp);
diff --git a/packages/app/index.ts b/packages/app/index.ts
index 060f848a71..03b518fd2c 100644
--- a/packages/app/index.ts
+++ b/packages/app/index.ts
@@ -8,4 +8,11 @@ polyfillScreenOrientation();
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
import "./src/styles/unistyles";
-import "expo-router/entry";
+// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
+import "@expo/metro-runtime";
+// oxlint-disable-next-line import/no-unassigned-import -- Preserve Expo's entry side effects.
+import "expo-router/build/fast-refresh";
+import { renderRootComponent } from "expo-router/build/renderRootComponent";
+import { RootApp } from "./src/root-app";
+
+renderRootComponent(RootApp);
diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx
index eebd8ee675..f35c533633 100644
--- a/packages/app/src/app/_layout.tsx
+++ b/packages/app/src/app/_layout.tsx
@@ -1,7 +1,6 @@
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
-import { QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import * as Notifications from "expo-notifications";
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
@@ -19,7 +18,6 @@ import {
import { AppState, useWindowDimensions, View } from "react-native";
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
import { KeyboardProvider } from "react-native-keyboard-controller";
-import { SafeAreaProvider } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AppearanceProvider } from "@/appearance/provider";
import { CommandCenter } from "@/command-center/command-center";
@@ -43,7 +41,6 @@ import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut
import { WorkspaceRenameHost } from "@/components/workspace-rename-host";
import { CompactExplorerSidebarHost } from "@/components/compact-explorer-sidebar-host";
import { ProviderSettingsHost } from "@/components/provider-settings-host";
-import { RootErrorBoundary } from "@/components/root-error-boundary";
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
import { WorkspaceShortcutTargetsSubscriber } from "@/components/workspace-shortcut-targets-subscriber";
import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
@@ -98,14 +95,12 @@ import { useAppSettings } from "@/hooks/use-settings";
import { useStableEvent } from "@/hooks/use-stable-event";
import { useOpenAgentListGesture } from "@/mobile-panels/gestures";
import { MobilePanelsProvider, useIsMobilePanelActive } from "@/mobile-panels/provider";
-import { I18nProvider } from "@/i18n/provider";
import {
KeyboardActionDispatcherProvider,
useKeyboardActionDispatcher,
} from "@/keyboard/keyboard-action-dispatcher-context";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
-import { queryClient } from "@/data/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
@@ -451,10 +446,6 @@ export function useHostRuntimeBootstrapState(): HostRuntimeBootstrapState {
return useContext(HostRuntimeBootstrapContext);
}
-function QueryProvider({ children }: { children: ReactNode }) {
- return {children};
-}
-
const rowStyle = { flex: 1, flexDirection: "row" } as const;
const flexStyle = { flex: 1 } as const;
const MOBILE_WEB_GESTURE_TOUCH_ACTION = isWeb ? "auto" : "pan-y";
@@ -1010,17 +1001,7 @@ export default function RootLayout() {
return () => subscription.remove();
}, []);
- return (
-
-
-
-
-
-
-
-
-
- );
+ return ;
}
const layoutStyles = StyleSheet.create((theme) => ({
diff --git a/packages/app/src/components/root-error-boundary.test.tsx b/packages/app/src/components/root-error-boundary.test.tsx
index de1a8225e8..12bf05ee43 100644
--- a/packages/app/src/components/root-error-boundary.test.tsx
+++ b/packages/app/src/components/root-error-boundary.test.tsx
@@ -71,25 +71,26 @@ afterEach(() => {
describe("RootErrorFallback", () => {
for (const breakpoint of ["xs", "lg"]) {
- it(`keeps the safe-area header and retry action outside the scrollable details on ${breakpoint}`, () => {
+ it(`keeps the safe-area header and reload action outside the scrollable details on ${breakpoint}`, () => {
runtime.breakpoint = breakpoint;
act(() => {
- root?.render();
+ root?.render();
});
const screen = document.querySelector('[data-testid="root-error-boundary"]');
const header = document.querySelector('[data-testid="root-error-boundary-header"]');
const details = document.querySelector('[data-testid="root-error-boundary-details"]');
- const retry = document.querySelector('[data-testid="root-error-boundary-retry"]');
+ const reload = document.querySelector('[data-testid="root-error-boundary-reload"]');
expect(screen).not.toBeNull();
expect(header).not.toBeNull();
expect(details).not.toBeNull();
- expect(retry).not.toBeNull();
+ expect(reload).not.toBeNull();
+ expect(reload?.textContent).toBe("common.actions.reload");
expect(screen?.getAttribute("style")).toContain("padding: 47px 3px 34px 5px");
expect(details?.contains(header)).toBe(false);
- expect(details?.contains(retry)).toBe(false);
- expect(screen?.contains(retry)).toBe(true);
+ expect(details?.contains(reload)).toBe(false);
+ expect(screen?.contains(reload)).toBe(true);
});
}
});
diff --git a/packages/app/src/components/root-error-boundary.tsx b/packages/app/src/components/root-error-boundary.tsx
index 1ed3de6707..01e8b79563 100644
--- a/packages/app/src/components/root-error-boundary.tsx
+++ b/packages/app/src/components/root-error-boundary.tsx
@@ -1,4 +1,4 @@
-import React, { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
+import React, { Component, type ErrorInfo, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -10,17 +10,16 @@ import { formatCaughtValue } from "./root-error-details";
interface RootErrorBoundaryProps {
children: ReactNode;
+ onReload: () => void;
}
interface RootErrorBoundaryState {
error: string | null;
- resetKey: number;
}
export class RootErrorBoundary extends Component {
state: RootErrorBoundaryState = {
error: null,
- resetKey: 0,
};
static getDerivedStateFromError(error: unknown): Partial {
@@ -34,40 +33,33 @@ export class RootErrorBoundary extends Component {
- this.setState(({ resetKey }) => ({
- error: null,
- resetKey: resetKey + 1,
- }));
- };
-
render() {
- const { error, resetKey } = this.state;
+ const { error } = this.state;
if (error !== null) {
- return ;
+ return ;
}
- return {this.props.children};
+ return this.props.children;
}
}
// A stack trace is reference material, not the screen. Cap it so the message and
-// the retry action stay the shape of the page; the rest scrolls.
+// the reload action stay the shape of the page; the rest scrolls.
const DETAILS_MAX_HEIGHT = 300;
interface RootErrorFallbackProps {
error: string;
- onRetry: () => void;
+ onReload: () => void;
}
-export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
+export function RootErrorFallback({ error, onReload }: RootErrorFallbackProps) {
const { t } = useTranslation();
const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
- const retry = (
-