Reload to the project picker after an app error (#4598)

* fix(app): reload to the project picker after a render error

Keep the recovery boundary above the router so Reload starts a new navigation tree at the picker instead of remounting the failed workspace. Preserve saved layouts and normal startup restoration.

* test(app): exercise reload through fixture routes
This commit is contained in:
Mohamed Boudra
2026-09-11 08:09:05 +02:00
committed by GitHub
parent 6f2195904b
commit 4006f974fb
17 changed files with 238 additions and 48 deletions
+9
View File
@@ -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
@@ -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(
`<!doctype html><html><head><meta name="viewport" content="width=device-width, initial-scale=1"><style>html,body,#root{height:100%;margin:0}#root{display:flex}</style></head><body><div id="root"></div><script src="${bundle}"></script></body></html>`,
);
});
await new Promise<void>((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<void>((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");
});
});
+55
View File
@@ -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 <Stack screenOptions={screenOptions} />;
}
// Fixture routes are absent from Expo's generated production route types.
function Startup() {
return <Redirect href={"/broken" as Href} />;
}
function BrokenWorkspace(): never {
throw new Error("Workspace recovery fixture");
}
function Picker() {
return (
<View>
<Text>Project picker</Text>
<Link href={"/healthy" as Href}>Open healthy workspace</Link>
</View>
);
}
function HealthyWorkspace() {
const [broken, setBroken] = useState(false);
const breakWorkspace = useCallback(() => setBroken(true), []);
if (broken) return <BrokenWorkspace />;
return (
<View>
<Text>Healthy workspace</Text>
<Button title="Break this workspace" onPress={breakWorkspace} />
</View>
);
}
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<typeof RootRouter>["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 <RootRouter context={context} />;
}
registerRootComponent(RecoveryApp);
+8 -1
View File
@@ -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);
+1 -20
View File
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
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 (
<QueryProvider>
<I18nProvider>
<SafeAreaProvider>
<RootErrorBoundary>
<RootAppTree />
</RootErrorBoundary>
</SafeAreaProvider>
</I18nProvider>
</QueryProvider>
);
return <RootAppTree />;
}
const layoutStyles = StyleSheet.create((theme) => ({
@@ -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(<RootErrorFallback error={"failure\n".repeat(200)} onRetry={vi.fn()} />);
root?.render(<RootErrorFallback error={"failure\n".repeat(200)} onReload={vi.fn()} />);
});
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);
});
}
});
@@ -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<RootErrorBoundaryProps, RootErrorBoundaryState> {
state: RootErrorBoundaryState = {
error: null,
resetKey: 0,
};
static getDerivedStateFromError(error: unknown): Partial<RootErrorBoundaryState> {
@@ -34,40 +33,33 @@ export class RootErrorBoundary extends Component<RootErrorBoundaryProps, RootErr
});
}
retry = () => {
this.setState(({ resetKey }) => ({
error: null,
resetKey: resetKey + 1,
}));
};
render() {
const { error, resetKey } = this.state;
const { error } = this.state;
if (error !== null) {
return <RootErrorFallback error={error} onRetry={this.retry} />;
return <RootErrorFallback error={error} onReload={this.props.onReload} />;
}
return <Fragment key={resetKey}>{this.props.children}</Fragment>;
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 = (
<Button variant="default" onPress={onRetry} testID="root-error-boundary-retry">
{t("common.actions.retry")}
const reload = (
<Button variant="default" onPress={onReload} testID="root-error-boundary-reload">
{t("common.actions.reload")}
</Button>
);
@@ -100,9 +92,9 @@ export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) {
{error}
</ScrollableCodeSurface>
</View>
{isCompact ? null : retry}
{isCompact ? null : reload}
</View>
{isCompact ? <View style={styles.footer}>{retry}</View> : null}
{isCompact ? <View style={styles.footer}>{reload}</View> : null}
</View>
);
}
+1
View File
@@ -12,6 +12,7 @@ export const ar: TranslationResources = {
copy: "ينسخ",
copyLine: "نسخ السطر",
dismiss: "رفض",
reload: "إعادة التحميل",
retry: "أعد المحاولة",
search: "يبحث",
select: "يختار",
+1
View File
@@ -9,6 +9,7 @@ export const en = {
copy: "Copy",
copyLine: "Copy line",
dismiss: "Dismiss",
reload: "Reload",
retry: "Retry",
search: "Search",
select: "Select",
+1
View File
@@ -12,6 +12,7 @@ export const es: TranslationResources = {
copy: "Copiar",
copyLine: "Copiar línea",
dismiss: "Despedir",
reload: "Volver a cargar",
retry: "Rever",
search: "Buscar",
select: "Seleccionar",
+1
View File
@@ -12,6 +12,7 @@ export const fr: TranslationResources = {
copy: "Copie",
copyLine: "Copier la ligne",
dismiss: "Rejeter",
reload: "Recharger",
retry: "Réessayer",
search: "Recherche",
select: "Sélectionner",
+1
View File
@@ -12,6 +12,7 @@ export const ja: TranslationResources = {
copy: "コピー",
copyLine: "行をコピー",
dismiss: "閉じる",
reload: "再読み込み",
retry: "再試行",
search: "検索",
select: "選択",
+1
View File
@@ -12,6 +12,7 @@ export const ko: TranslationResources = {
copy: "복사",
copyLine: "줄 복사",
dismiss: "닫기",
reload: "다시 로드",
retry: "다시 시도",
search: "검색",
select: "선택",
+1
View File
@@ -12,6 +12,7 @@ export const ptBR: TranslationResources = {
copy: "Copiar",
copyLine: "Copiar linha",
dismiss: "Dispensar",
reload: "Recarregar",
retry: "Tentar novamente",
search: "Buscar",
select: "Selecionar",
+1
View File
@@ -12,6 +12,7 @@ export const ru: TranslationResources = {
copy: "Копировать",
copyLine: "Копировать строку",
dismiss: "Отклонить",
reload: "Перезагрузить",
retry: "Повторить",
search: "Поиск",
select: "Выбрать",
+1
View File
@@ -12,6 +12,7 @@ export const zhCN: TranslationResources = {
copy: "复制",
copyLine: "复制行",
dismiss: "关闭",
reload: "重新加载",
retry: "重试",
search: "搜索",
select: "选择",
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useState, type ComponentProps } from "react";
import { ExpoRoot } from "expo-router";
import Head from "expo-router/head";
import { QueryClientProvider } from "@tanstack/react-query";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { queryClient } from "@/data/query-client";
import { I18nProvider } from "@/i18n/provider";
import { RootErrorBoundary } from "@/components/root-error-boundary";
// Keep Expo's platform filtering, route root, and lazy import configuration.
const { ctx: context } = require("expo-router/_ctx") as {
ctx: ComponentProps<typeof ExpoRoot>["context"];
};
export function RootApp() {
return <RootRouter context={context} />;
}
export function RootRouter({ context: routes }: Pick<ComponentProps<typeof ExpoRoot>, "context">) {
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
return (
<QueryClientProvider client={queryClient}>
<I18nProvider>
<SafeAreaProvider>
<RootErrorBoundary key={generation} onReload={reload}>
<Head.Provider>
{/* Recreate the router at a safe destination before a failed route can mount. */}
<ExpoRoot
context={routes}
location={generation === 0 ? undefined : "/open-project"}
/>
</Head.Provider>
</RootErrorBoundary>
</SafeAreaProvider>
</I18nProvider>
</QueryClientProvider>
);
}