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 + ); @@ -100,9 +92,9 @@ export function RootErrorFallback({ error, onRetry }: RootErrorFallbackProps) { {error} - {isCompact ? null : retry} + {isCompact ? null : reload} - {isCompact ? {retry} : null} + {isCompact ? {reload} : null} ); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1f3a5b3b02..30adccccd7 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -12,6 +12,7 @@ export const ar: TranslationResources = { copy: "ينسخ", copyLine: "نسخ السطر", dismiss: "رفض", + reload: "إعادة التحميل", retry: "أعد المحاولة", search: "يبحث", select: "يختار", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 831bc8eb18..e738f13e82 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -9,6 +9,7 @@ export const en = { copy: "Copy", copyLine: "Copy line", dismiss: "Dismiss", + reload: "Reload", retry: "Retry", search: "Search", select: "Select", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 810a29c86e..2c4abbc907 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 56e11702c1..6b26d97812 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 7ab9c3f3a4..bcb85cd9a6 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -12,6 +12,7 @@ export const ja: TranslationResources = { copy: "コピー", copyLine: "行をコピー", dismiss: "閉じる", + reload: "再読み込み", retry: "再試行", search: "検索", select: "選択", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 394c8474e3..6b995ffc72 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -12,6 +12,7 @@ export const ko: TranslationResources = { copy: "복사", copyLine: "줄 복사", dismiss: "닫기", + reload: "다시 로드", retry: "다시 시도", search: "검색", select: "선택", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index b14a5e61bc..e6b1acdb32 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -12,6 +12,7 @@ export const ptBR: TranslationResources = { copy: "Copiar", copyLine: "Copiar linha", dismiss: "Dispensar", + reload: "Recarregar", retry: "Tentar novamente", search: "Buscar", select: "Selecionar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9ebde808b0..23b29edb13 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -12,6 +12,7 @@ export const ru: TranslationResources = { copy: "Копировать", copyLine: "Копировать строку", dismiss: "Отклонить", + reload: "Перезагрузить", retry: "Повторить", search: "Поиск", select: "Выбрать", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 73ef534bd9..32442aba5f 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -12,6 +12,7 @@ export const zhCN: TranslationResources = { copy: "复制", copyLine: "复制行", dismiss: "关闭", + reload: "重新加载", retry: "重试", search: "搜索", select: "选择", diff --git a/packages/app/src/root-app.tsx b/packages/app/src/root-app.tsx new file mode 100644 index 0000000000..cae9b169d6 --- /dev/null +++ b/packages/app/src/root-app.tsx @@ -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["context"]; +}; + +export function RootApp() { + return ; +} + +export function RootRouter({ context: routes }: Pick, "context">) { + const [generation, setGeneration] = useState(0); + const reload = useCallback(() => setGeneration((value) => value + 1), []); + + return ( + + + + + + {/* Recreate the router at a safe destination before a failed route can mount. */} + + + + + + + ); +}