From 73526559b59d86b73f4c4cd7363ee665c22efb3f Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 23 Oct 2026 12:06:38 -0700 Subject: [PATCH] fix(ui): reload once when a lazy chunk or its CSS fails to load (#17349) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board UI loads each page as a lazy chunk. Vite adds a preload step that also loads the CSS of the chunk. > - Staging error monitoring reported `Unable to preload CSS for …/assets/issue-detail-CIbQo9zW.css`. The same file name comes from the current build, so the CSS did not change. One request for the file failed for a short time. > - Vite rejects only the first import that asks for a failed file. Later imports skip that file. On a task page, the first import is the task-page prefetch, which ignores errors. The page then shows without its CSS. On other pages that share the CSS, such as Agents, the lazy route rejects and the route error boundary shows the error. > - Neither case recovers without a manual reload. This is a routine retry that the app can do safely, so the app must do it. > - This pull request reloads the page one time when a chunk or its CSS fails to load. A loop guard limits this to one reload in five minutes. > - The benefit is that a stale tab after a deploy, or one failed asset request, recovers without an error page, without missing styles, and without error-monitoring noise. An asset that stays broken still shows the error and still sends a report. ## Linked Issues or Issue Description **What happened?** A board page showed "This page hit an error" with `Unable to preload CSS for /assets/issue-detail-.css`. On a task page, the same failure gave no error message, but the page showed without its stylesheet. **Steps to reproduce** 1. Build the UI and start the server. 2. Make the first request for `/assets/issue-detail-*.css` fail. The new Playwright spec does this with `page.route(...).abort("failed")`. 3. Open `//agents`. The route error boundary shows "Unable to preload CSS". 4. Open `//issues/`. The task shows, but the browser does not request the task CSS again. The page has no task-page CSS. **Expected behavior** The app recovers from a chunk or CSS load failure without user action. If the failure continues, the app shows the error one time, with no reload loop. **Environment** Hosted staging browser. The local build of current `master` reproduces it. I searched open and closed PRs for `preload`, `preloadError`, and `stale chunk`. I found no related PR. ## What Changed - `ui/src/lib/chunk-load-recovery.ts` (new): `isChunkLoadError` matches the Vite CSS preload error and the dynamic import errors from Chromium, Firefox, and WebKit. `reloadForChunkLoadError` reloads one time. A `sessionStorage` marker blocks a second reload for five minutes. After the first reload, later chunk errors in the same document are part of that reload, so they return "reloading" and do not show an error. Without `sessionStorage`, the function does not reload, because it cannot guard against a loop. `startChunkLoadRecovery` listens for `vite:preloadError`. It does not cancel the event, so the import still rejects as before. - `ui/src/main.tsx`: Starts the listener before the first render. The listener covers every import, including the task-page prefetch that ignores errors. - `RouteErrorBoundary` and `AppErrorBoundary`: While a recovery reload runs, they show a loading state. They do not show the error page and do not report to Sentry. Other errors work as before. - Tests: Vitest unit tests for the helper and both boundaries. A new Playwright spec, `tests/e2e/chunk-load-recovery.spec.ts`, runs against the real server and the built UI. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/lib/chunk-load-recovery.test.ts src/components/RouteErrorBoundary.test.tsx src/components/AppErrorBoundary.test.tsx`: 21 tests pass. - Full UI Vitest suite: 793 files, 9,075 tests pass. - `pnpm --filter @paperclipai/ui typecheck` passes. `node scripts/check-token-gates.mjs` is clean. `node scripts/check-maps.mjs` passes. - `npx playwright test --config tests/e2e/playwright.config.ts chunk-load-recovery.spec.ts`: 3 tests pass. - Task page with one failed CSS request: one reload, the CSS loads, and the task shows. - Agents page with one failed CSS request: one reload, the CSS loads, and the page shows with no error page. - CSS that always fails: one reload, then the error page shows. There is no second reload. - Red proof: I ran the same spec with the `ui/src` changes removed (current `master` UI code). Both recovery tests fail. On Agents, the route error boundary shows `Unable to preload CSS for http://127.0.0.1:3199/assets/issue-detail-CIbQo9zW.css`. On the task page, the CSS is never requested again. - A failure I found and fixed during the work: my first version changed only the error boundaries. The task-page e2e case failed with that version, because the prefetch received the error and the boundary never saw it. The `vite:preloadError` listener fixes that case. All results above are from the final commit. - The spec blocks service workers, because Playwright page routes cannot see requests that a service worker sends. With a worker, a failed network request for an asset that is not cached also gives `Response.error()`. Thus the preload error is the same. ## Risks - A page can reload while a user is on it. This occurs only after a chunk or CSS load failure, and at most one time in five minutes per tab session. Without this change, that page already shows an error or shows without styles. Composer drafts use browser storage, so a reload keeps them. - A failure that recovers no longer sends a Sentry event. A failure that continues after the reload still sends one, so a broken deploy stays visible. - The error patterns match browser and Vite message prefixes. If a browser changes its message, that browser goes back to the current behavior: the error page and a report. - No API, schema, or server changes. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, run as a Paperclip agent through Claude Code with tool use (shell, file edits, Playwright, and Vitest). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> --- tests/e2e/chunk-load-recovery.spec.ts | 81 +++++++++ ui/src/App.tsx | 4 +- ui/src/components/AppErrorBoundary.test.tsx | 66 +++++++- ui/src/components/AppErrorBoundary.tsx | 36 +++- ui/src/components/RouteErrorBoundary.test.tsx | 67 +++++++- ui/src/components/RouteErrorBoundary.tsx | 36 +++- ui/src/lib/chunk-load-recovery.test.ts | 155 ++++++++++++++++++ ui/src/lib/chunk-load-recovery.ts | 94 +++++++++++ ui/src/main.tsx | 3 + 9 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 tests/e2e/chunk-load-recovery.spec.ts create mode 100644 ui/src/lib/chunk-load-recovery.test.ts create mode 100644 ui/src/lib/chunk-load-recovery.ts diff --git a/tests/e2e/chunk-load-recovery.spec.ts b/tests/e2e/chunk-load-recovery.spec.ts new file mode 100644 index 00000000000..f3f9c4c81d0 --- /dev/null +++ b/tests/e2e/chunk-load-recovery.spec.ts @@ -0,0 +1,81 @@ +import { randomUUID } from "node:crypto"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +async function json(response: Awaited>) { + expect(response.ok(), `${response.status()}: ${await response.text()}`).toBe(true); + return response.json(); +} + +async function failFirstTaskCss(page: Page) { + const counts = { failed: 0, served: 0, documents: 0 }; + await page.route(/\/assets\/issue-detail-[^/]+\.css$/, async (route) => { + if (counts.failed === 0) { + counts.failed += 1; + await route.abort("failed"); + return; + } + counts.served += 1; + await route.continue(); + }); + page.on("request", (request) => { + if (request.resourceType() === "document" && request.frame() === page.mainFrame()) counts.documents += 1; + }); + return counts; +} + +test.use({ serviceWorkers: "block" }); + +for (const entry of ["task", "agents"] as const) { + test(`a failed task-page CSS preload recovers with one reload (${entry} page)`, async ({ page, request }) => { + const company = await json(await request.post("/api/companies", { + data: { name: `Chunk recovery ${randomUUID()}` }, + })); + const issue = await json(await request.post(`/api/companies/${company.id}/issues`, { + data: { title: "Open after a failed stylesheet", status: "backlog" }, + })); + const counts = await failFirstTaskCss(page); + + if (entry === "task") { + await page.goto(`/${company.issuePrefix}/issues/${issue.identifier}`); + await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible(); + } else { + await page.goto(`/${company.issuePrefix}/agents`); + await expect(page.getByRole("heading", { name: "Agents", exact: true }).first()).toBeVisible(); + } + + await expect(page.getByText("This page hit an error")).toHaveCount(0); + expect(counts.failed).toBe(1); + expect(counts.served).toBeGreaterThan(0); + expect(counts.documents).toBe(2); + }); +} + +for (const entry of ["task", "agents"] as const) { + test(`a task-page CSS asset that stays broken reloads once, then shows the error (${entry} page)`, async ({ page, request }) => { + const company = await json(await request.post("/api/companies", { + data: { name: `Chunk recovery ${randomUUID()}` }, + })); + const issue = await json(await request.post(`/api/companies/${company.id}/issues`, { + data: { title: "Open with a broken stylesheet", status: "backlog" }, + })); + let failed = 0; + let documents = 0; + await page.route(/\/assets\/issue-detail-[^/]+\.css$/, async (route) => { + failed += 1; + await route.abort("failed"); + }); + page.on("request", (request) => { + if (request.resourceType() === "document" && request.frame() === page.mainFrame()) documents += 1; + }); + + await page.goto(entry === "task" + ? `/${company.issuePrefix}/issues/${issue.identifier}` + : `/${company.issuePrefix}/agents`); + + await expect(page.getByText("This page hit an error")).toBeVisible(); + await expect(page.getByText(/Unable to preload CSS for .*issue-detail-/)).toBeVisible(); + await page.waitForTimeout(1_000); + expect(documents).toBe(2); + expect(failed).toBe(2); + }); +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 24f19888e05..b556a3cbac1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -54,7 +54,9 @@ const ProductionLayout = lazy(() => import("./components/Layout.production").the const TaskChatLab = lazy(() => import("./pages/TaskChatLab").then((module) => ({ default: module.TaskChatLab }))); const Issues = lazy(() => import("./pages/Issues").then((module) => ({ default: module.Issues }))); -const loadIssueDetail = () => import("./pages/IssueDetail").then((module) => ({ default: module.IssueDetail })); +let issueDetailLoad: Promise<{ default: typeof import("./pages/IssueDetail").IssueDetail }> | undefined; +const loadIssueDetail = () => + (issueDetailLoad ??= import("./pages/IssueDetail").then((module) => ({ default: module.IssueDetail }))); const IssueDetail = lazy(loadIssueDetail); const AgentChat = lazy(() => import("./pages/AgentChat").then((module) => ({ default: module.AgentChat }))); const IssueChatLongThreadPerf = lazy(() => import("./pages/IssueChatLongThreadPerf").then((module) => ({ default: module.IssueChatLongThreadPerf }))); diff --git a/ui/src/components/AppErrorBoundary.test.tsx b/ui/src/components/AppErrorBoundary.test.tsx index d5bd8919038..f5bdd90fa4c 100644 --- a/ui/src/components/AppErrorBoundary.test.tsx +++ b/ui/src/components/AppErrorBoundary.test.tsx @@ -1,16 +1,31 @@ // @vitest-environment jsdom -import { act, useEffect } from "react"; +import { act, lazy, Suspense, useEffect } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createChunkLoadRecovery, RELOAD_ABANDONED_MS } from "@/lib/chunk-load-recovery"; import { AppErrorBoundary } from "./AppErrorBoundary"; const captureBrowserExceptionMock = vi.hoisted(() => vi.fn()); +const reloadMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/sentry", () => ({ captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args), })); +const chunkRecovery = vi.hoisted(() => ({ + current: null as null | ReturnType, +})); + +vi.mock("@/lib/chunk-load-recovery", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reloadForChunkLoadError: (error: unknown) => chunkRecovery.current!.reloadForChunkLoadError(error), + onChunkLoadReloadAbandoned: (listener: () => void) => chunkRecovery.current!.onChunkLoadReloadAbandoned(listener), + }; +}); + function BoomRender(): never { throw new Error("Maximum update depth exceeded"); } @@ -27,6 +42,9 @@ describe("AppErrorBoundary", () => { let consoleErrorSpy: ReturnType; beforeEach(() => { + reloadMock.mockReset(); + window.sessionStorage.clear(); + chunkRecovery.current = createChunkLoadRecovery({ reload: reloadMock }); container = document.createElement("div"); document.body.appendChild(container); // React logs caught render errors to console.error; silence the expected noise. @@ -68,6 +86,52 @@ describe("AppErrorBoundary", () => { }); }); + it("reloads once for a failed lazy chunk, then shows the prompt if it fails again", async () => { + const chunkError = () => new Error("Failed to fetch dynamically imported module: https://paperclip.example/assets/layout.js"); + const root = createRoot(container); + const FailedLayout = lazy(async () => { throw chunkError(); }); + await act(async () => { + root.render(); + }); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).not.toHaveBeenCalled(); + expect(container.querySelector('[role="status"]')?.textContent).toContain("Reloading Paperclip"); + act(() => root.unmount()); + + chunkRecovery.current = createChunkLoadRecovery({ reload: reloadMock }); + const nextRoot = createRoot(container); + const StillFailedLayout = lazy(async () => { throw chunkError(); }); + await act(async () => { + nextRoot.render(); + }); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("Paperclip hit an error"); + act(() => nextRoot.unmount()); + }); + + it("shows and reports the error when the recovery reload is cancelled", async () => { + vi.useFakeTimers(); + try { + const root = createRoot(container); + const FailedLayout = lazy(async () => { + throw new Error("Failed to fetch dynamically imported module: https://paperclip.example/assets/layout.js"); + }); + await act(async () => { + root.render(); + }); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).not.toHaveBeenCalled(); + + act(() => { vi.advanceTimersByTime(RELOAD_ABANDONED_MS); }); + expect(container.textContent).toContain("Paperclip hit an error"); + expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + } finally { + vi.useRealTimers(); + } + }); + it("catches errors thrown from effects, not just render", () => { const root = createRoot(container); act(() => { diff --git a/ui/src/components/AppErrorBoundary.tsx b/ui/src/components/AppErrorBoundary.tsx index 671259edbdb..97780600f27 100644 --- a/ui/src/components/AppErrorBoundary.tsx +++ b/ui/src/components/AppErrorBoundary.tsx @@ -1,8 +1,10 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; +import { onChunkLoadReloadAbandoned, reloadForChunkLoadError } from "@/lib/chunk-load-recovery"; import { captureBrowserException } from "@/lib/sentry"; type AppErrorBoundaryState = { error: Error | null; + reloading: boolean; }; /** @@ -17,20 +19,48 @@ type AppErrorBoundaryState = { * crash being handled may have originated inside any of those providers. */ export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErrorBoundaryState> { - override state: AppErrorBoundaryState = { error: null }; + override state: AppErrorBoundaryState = { error: null, reloading: false }; + private stopWaitingForReload: (() => void) | null = null; - static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { + static getDerivedStateFromError(error: unknown): Partial { return { error: error instanceof Error ? error : new Error(String(error)) }; } override componentDidCatch(error: unknown, info: ErrorInfo): void { + this.stopWaitingForReload?.(); + this.stopWaitingForReload = null; + if (reloadForChunkLoadError(error)) { + this.setState({ reloading: true }); + this.stopWaitingForReload = onChunkLoadReloadAbandoned(() => { + this.stopWaitingForReload = null; + this.setState({ reloading: false }); + this.report(error, info); + }); + return; + } + this.report(error, info); + } + + override componentWillUnmount(): void { + this.stopWaitingForReload?.(); + this.stopWaitingForReload = null; + } + + private report(error: unknown, info: ErrorInfo): void { console.error("App shell crashed", { error, componentStack: info.componentStack }); captureBrowserException(error, { boundary: "app", componentStack: info.componentStack }); } override render() { - const { error } = this.state; + const { error, reloading } = this.state; if (!error) return this.props.children; + if (reloading) { + return ( +
+ Reloading Paperclip… +
+ ); + } return (
diff --git a/ui/src/components/RouteErrorBoundary.test.tsx b/ui/src/components/RouteErrorBoundary.test.tsx index 1a501949661..81f5ee80fea 100644 --- a/ui/src/components/RouteErrorBoundary.test.tsx +++ b/ui/src/components/RouteErrorBoundary.test.tsx @@ -3,11 +3,13 @@ import { act, lazy } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createChunkLoadRecovery, RELOAD_ABANDONED_MS } from "@/lib/chunk-load-recovery"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; const navigateMock = vi.hoisted(() => vi.fn()); const routerLocation = vi.hoisted(() => ({ current: { pathname: "/co/agents/new", search: "?adapterType=claude_local" } })); const captureBrowserExceptionMock = vi.hoisted(() => vi.fn()); +const reloadMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ useLocation: () => routerLocation.current, @@ -18,6 +20,19 @@ vi.mock("@/lib/sentry", () => ({ captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args), })); +const chunkRecovery = vi.hoisted(() => ({ + current: null as null | ReturnType, +})); + +vi.mock("@/lib/chunk-load-recovery", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reloadForChunkLoadError: (error: unknown) => chunkRecovery.current!.reloadForChunkLoadError(error), + onChunkLoadReloadAbandoned: (listener: () => void) => chunkRecovery.current!.onChunkLoadReloadAbandoned(listener), + }; +}); + function Boom(): never { throw new Error("Maximum update depth exceeded"); } @@ -29,6 +44,9 @@ describe("RouteErrorBoundary", () => { beforeEach(() => { navigateMock.mockReset(); routerLocation.current = { pathname: "/co/agents/new", search: "?adapterType=claude_local" }; + reloadMock.mockReset(); + window.sessionStorage.clear(); + chunkRecovery.current = createChunkLoadRecovery({ reload: reloadMock }); container = document.createElement("div"); document.body.appendChild(container); // React logs caught render errors to console.error; silence the expected noise. @@ -63,10 +81,57 @@ describe("RouteErrorBoundary", () => { act(() => root.unmount()); }); - it("contains a failed chunk load and recovers when navigating back to a task", async () => { + it("reloads once for a failed route CSS preload instead of reporting it", async () => { + const FailedPage = lazy(async () => { + throw new Error("Unable to preload CSS for https://paperclip.example/assets/issue-detail-CIbQo9zW.css"); + }); + const root = createRoot(container); + await act(async () => { root.render(); }); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).not.toHaveBeenCalled(); + expect(container.querySelector('[role="status"]')?.textContent).toContain("Loading"); + expect(container.textContent).not.toContain("This page hit an error"); + act(() => root.unmount()); + }); + + it("shows and reports the error when the recovery reload is cancelled", async () => { + vi.useFakeTimers(); + try { + const FailedPage = lazy(async () => { + throw new Error("Unable to preload CSS for https://paperclip.example/assets/issue-detail-CIbQo9zW.css"); + }); + const root = createRoot(container); + await act(async () => { root.render(); }); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(container.querySelector('[role="status"]')?.textContent).toContain("Loading"); + + act(() => { vi.advanceTimersByTime(RELOAD_ABANDONED_MS); }); + expect(container.textContent).toContain("This page hit an error"); + expect(container.textContent).toContain("Unable to preload CSS"); + expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), { + boundary: "route", componentStack: expect.any(String), + }); + + const AnotherFailedPage = lazy(async () => { throw new Error("Failed to fetch dynamically imported module"); }); + routerLocation.current = { pathname: "/co/issues/CO-1", search: "" }; + await act(async () => { root.render(); }); + expect(reloadMock).toHaveBeenCalledTimes(1); + expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain("Failed to fetch dynamically imported module"); + act(() => root.unmount()); + } finally { + vi.useRealTimers(); + } + }); + + it("contains a repeated chunk load failure and recovers when navigating back to a task", async () => { + window.sessionStorage.setItem("paperclip:chunk-load-reload-at", String(Date.now())); const FailedPage = lazy(async () => { throw new Error("Failed to fetch dynamically imported module"); }); const root = createRoot(container); await act(async () => { root.render(); }); + expect(reloadMock).not.toHaveBeenCalled(); + expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1); expect(container.textContent).toContain("This page hit an error"); routerLocation.current = { pathname: "/co/issues/CO-1", search: "" }; await act(async () => { root.render(
Saved conversation
); }); diff --git a/ui/src/components/RouteErrorBoundary.tsx b/ui/src/components/RouteErrorBoundary.tsx index aba128c130f..be006f19619 100644 --- a/ui/src/components/RouteErrorBoundary.tsx +++ b/ui/src/components/RouteErrorBoundary.tsx @@ -1,6 +1,7 @@ import { Component, Suspense, type ErrorInfo, type ReactNode } from "react"; import { useLocation, useNavigate } from "@/lib/router"; import { Button } from "@/components/ui/button"; +import { onChunkLoadReloadAbandoned, reloadForChunkLoadError } from "@/lib/chunk-load-recovery"; import { captureBrowserException } from "@/lib/sentry"; import { PaperclipLoading } from "./AnimatedPaperclipIcon"; @@ -12,16 +13,41 @@ type RouteErrorBoundaryInnerProps = { type RouteErrorBoundaryState = { error: Error | null; + reloading: boolean; }; class RouteErrorBoundaryInner extends Component { - override state: RouteErrorBoundaryState = { error: null }; + override state: RouteErrorBoundaryState = { error: null, reloading: false }; + private stopWaitingForReload: (() => void) | null = null; - static getDerivedStateFromError(error: unknown): RouteErrorBoundaryState { + static getDerivedStateFromError(error: unknown): Partial { return { error: error instanceof Error ? error : new Error(String(error)) }; } override componentDidCatch(error: unknown, info: ErrorInfo): void { + this.stopWaiting(); + if (reloadForChunkLoadError(error)) { + this.setState({ reloading: true }); + this.stopWaitingForReload = onChunkLoadReloadAbandoned(() => { + this.stopWaitingForReload = null; + this.setState({ reloading: false }); + this.report(error, info); + }); + return; + } + this.report(error, info); + } + + override componentWillUnmount(): void { + this.stopWaiting(); + } + + private stopWaiting(): void { + this.stopWaitingForReload?.(); + this.stopWaitingForReload = null; + } + + private report(error: unknown, info: ErrorInfo): void { console.error("Page render failed", { error, componentStack: info.componentStack }); captureBrowserException(error, { boundary: "route", componentStack: info.componentStack }); } @@ -31,13 +57,15 @@ class RouteErrorBoundaryInner extends Component, so reset whenever the route changes. if (this.state.error && prevProps.resetKey !== this.props.resetKey) { - this.setState({ error: null }); + this.stopWaiting(); + this.setState({ error: null, reloading: false }); } } override render() { - const { error } = this.state; + const { error, reloading } = this.state; if (!error) return this.props.children; + if (reloading) return ; return (
diff --git a/ui/src/lib/chunk-load-recovery.test.ts b/ui/src/lib/chunk-load-recovery.test.ts new file mode 100644 index 00000000000..63e5460d8cf --- /dev/null +++ b/ui/src/lib/chunk-load-recovery.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createChunkLoadRecovery, isChunkLoadError, RELOAD_ABANDONED_MS } from "./chunk-load-recovery"; + +function memoryStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }; +} + +const cssPreloadError = new Error( + "Unable to preload CSS for https://paperclip.example/assets/issue-detail-CIbQo9zW.css", +); + +function preloadErrorEvent(payload: unknown) { + return Object.assign(new Event("vite:preloadError", { cancelable: true }), { payload }); +} + +describe("isChunkLoadError", () => { + it("matches Vite CSS preload and browser dynamic import failures", () => { + expect(isChunkLoadError(cssPreloadError)).toBe(true); + expect(isChunkLoadError(new TypeError("Failed to fetch dynamically imported module: https://paperclip.example/assets/a.js"))).toBe(true); + expect(isChunkLoadError(new TypeError("error loading dynamically imported module: https://paperclip.example/assets/a.js"))).toBe(true); + expect(isChunkLoadError(new TypeError("Importing a module script failed."))).toBe(true); + }); + + it("ignores ordinary render errors and non-errors", () => { + expect(isChunkLoadError(new Error("Maximum update depth exceeded"))).toBe(false); + expect(isChunkLoadError(new Error("Request failed: Unable to preload CSS for x"))).toBe(false); + expect(isChunkLoadError("Unable to preload CSS for x")).toBe(false); + expect(isChunkLoadError(undefined)).toBe(false); + }); +}); + +describe("reloadForChunkLoadError", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("reloads once and treats later failures in the same document as that reload", () => { + const reload = vi.fn(); + const { reloadForChunkLoadError } = createChunkLoadRecovery({ storage: () => memoryStorage(), reload }); + + expect(reloadForChunkLoadError(cssPreloadError)).toBe(true); + expect(reloadForChunkLoadError(cssPreloadError)).toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("defers to the error UI when the fresh page fails again", () => { + const storage = memoryStorage(); + const reload = vi.fn(); + let now = 1_000_000; + createChunkLoadRecovery({ storage: () => storage, reload, now: () => now }).reloadForChunkLoadError(cssPreloadError); + + now += 10_000; + const freshPage = createChunkLoadRecovery({ storage: () => storage, reload, now: () => now }); + expect(freshPage.reloadForChunkLoadError(cssPreloadError)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("notifies waiting callers and stops claiming the failure when the reload is cancelled", () => { + vi.useFakeTimers(); + const storage = memoryStorage(); + const reload = vi.fn(); + const { reloadForChunkLoadError, onChunkLoadReloadAbandoned } = createChunkLoadRecovery({ + storage: () => storage, + reload, + }); + expect(reloadForChunkLoadError(cssPreloadError)).toBe(true); + const abandoned = vi.fn(); + const stopped = vi.fn(); + onChunkLoadReloadAbandoned(abandoned); + onChunkLoadReloadAbandoned(stopped)(); + + vi.advanceTimersByTime(RELOAD_ABANDONED_MS - 1); + expect(abandoned).not.toHaveBeenCalled(); + expect(reloadForChunkLoadError(cssPreloadError)).toBe(true); + + vi.advanceTimersByTime(1); + expect(abandoned).toHaveBeenCalledTimes(1); + expect(stopped).not.toHaveBeenCalled(); + expect(reloadForChunkLoadError(cssPreloadError)).toBe(false); + expect(reload).toHaveBeenCalledTimes(1); + + const late = vi.fn(); + onChunkLoadReloadAbandoned(late); + expect(late).toHaveBeenCalledTimes(1); + }); + + it("recovers again after the guard window, such as after a later deploy", () => { + const storage = memoryStorage(); + const reload = vi.fn(); + let now = 1_000_000; + createChunkLoadRecovery({ storage: () => storage, reload, now: () => now }).reloadForChunkLoadError(cssPreloadError); + + now += 5 * 60 * 1000; + const laterPage = createChunkLoadRecovery({ storage: () => storage, reload, now: () => now }); + expect(laterPage.reloadForChunkLoadError(cssPreloadError)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); + + it("ignores an unreadable stored marker", () => { + const reload = vi.fn(); + const storage = memoryStorage({ "paperclip:chunk-load-reload-at": "not-a-time" }); + const { reloadForChunkLoadError } = createChunkLoadRecovery({ storage: () => storage, reload, now: () => 1_000 }); + expect(reloadForChunkLoadError(cssPreloadError)).toBe(true); + }); + + it("never reloads for other errors", () => { + const reload = vi.fn(); + const { reloadForChunkLoadError } = createChunkLoadRecovery({ storage: () => memoryStorage(), reload }); + expect(reloadForChunkLoadError(new Error("boom"))).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + + it("never reloads without a working loop guard", () => { + const reload = vi.fn(); + expect(createChunkLoadRecovery({ storage: () => null, reload }).reloadForChunkLoadError(cssPreloadError)).toBe(false); + const throwing = { + getItem: () => { throw new Error("SecurityError"); }, + setItem: () => { throw new Error("SecurityError"); }, + }; + expect(createChunkLoadRecovery({ storage: () => throwing, reload }).reloadForChunkLoadError(cssPreloadError)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); +}); + +describe("startChunkLoadRecovery", () => { + it("reloads for a Vite preload failure without cancelling the rejection", () => { + const target = new EventTarget(); + const reload = vi.fn(); + const { startChunkLoadRecovery } = createChunkLoadRecovery({ storage: () => memoryStorage(), reload }); + const stop = startChunkLoadRecovery(target as unknown as Window); + + const event = preloadErrorEvent(cssPreloadError); + target.dispatchEvent(event); + expect(reload).toHaveBeenCalledTimes(1); + expect(event.defaultPrevented).toBe(false); + + stop(); + target.dispatchEvent(preloadErrorEvent(cssPreloadError)); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("ignores preload events without a chunk load error", () => { + const target = new EventTarget(); + const reload = vi.fn(); + const { startChunkLoadRecovery } = createChunkLoadRecovery({ storage: () => memoryStorage(), reload }); + startChunkLoadRecovery(target as unknown as Window); + target.dispatchEvent(preloadErrorEvent(undefined)); + target.dispatchEvent(preloadErrorEvent(new Error("boom"))); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/lib/chunk-load-recovery.ts b/ui/src/lib/chunk-load-recovery.ts new file mode 100644 index 00000000000..a7f380817c4 --- /dev/null +++ b/ui/src/lib/chunk-load-recovery.ts @@ -0,0 +1,94 @@ +const RELOAD_AT_KEY = "paperclip:chunk-load-reload-at"; +const RELOAD_GUARD_MS = 5 * 60 * 1000; +export const RELOAD_ABANDONED_MS = 10_000; + +const CHUNK_LOAD_ERROR_PATTERNS = [ + /^Unable to preload CSS for /, + /^Failed to fetch dynamically imported module/, + /^error loading dynamically imported module/, + /^Importing a module script failed/, +]; + +export function isChunkLoadError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return CHUNK_LOAD_ERROR_PATTERNS.some((pattern) => pattern.test(error.message)); +} + +type ReloadStorage = Pick; + +type ChunkLoadRecoveryOptions = { + storage?: () => ReloadStorage | null; + reload?: () => void; + now?: () => number; +}; + +function browserSessionStorage(): ReloadStorage | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} + +export function createChunkLoadRecovery(options: ChunkLoadRecoveryOptions = {}) { + const readStorage = options.storage ?? browserSessionStorage; + const reload = options.reload ?? (() => window.location.reload()); + const now = options.now ?? Date.now; + let reloading = false; + const abandonedListeners = new Set<() => void>(); + + function abandonReload() { + reloading = false; + const listeners = [...abandonedListeners]; + abandonedListeners.clear(); + for (const listener of listeners) listener(); + } + + function reloadForChunkLoadError(error: unknown): boolean { + if (!isChunkLoadError(error)) return false; + if (reloading) return true; + const storage = readStorage(); + if (!storage) return false; + const at = now(); + try { + const lastReloadAt = Number(storage.getItem(RELOAD_AT_KEY)); + if (Number.isFinite(lastReloadAt) && lastReloadAt > 0 && at - lastReloadAt < RELOAD_GUARD_MS) { + return false; + } + storage.setItem(RELOAD_AT_KEY, String(at)); + } catch { + return false; + } + reloading = true; + reload(); + globalThis.setTimeout(abandonReload, RELOAD_ABANDONED_MS); + return true; + } + + function onChunkLoadReloadAbandoned(listener: () => void): () => void { + if (!reloading) { + listener(); + return () => {}; + } + abandonedListeners.add(listener); + return () => { + abandonedListeners.delete(listener); + }; + } + + function startChunkLoadRecovery(target: Pick = window) { + const onPreloadError = (event: Event) => { + reloadForChunkLoadError((event as Event & { payload?: unknown }).payload); + }; + target.addEventListener("vite:preloadError", onPreloadError); + return () => target.removeEventListener("vite:preloadError", onPreloadError); + } + + return { reloadForChunkLoadError, onChunkLoadReloadAbandoned, startChunkLoadRecovery }; +} + +const browserRecovery = createChunkLoadRecovery(); + +export const reloadForChunkLoadError = browserRecovery.reloadForChunkLoadError; +export const onChunkLoadReloadAbandoned = browserRecovery.onChunkLoadReloadAbandoned; +export const startChunkLoadRecovery = browserRecovery.startChunkLoadRecovery; diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 239bc828292..8bc5dba71c6 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -22,6 +22,7 @@ import { DesignSandboxProvider } from "./context/DesignSandboxContext"; import { TooltipProvider } from "@/components/ui/tooltip"; import { initPluginBridge } from "./plugins/bridge-init"; import { PluginLauncherProvider } from "./plugins/launchers"; +import { startChunkLoadRecovery } from "./lib/chunk-load-recovery"; import { startPerfMeasureReaper } from "./lib/perf-measure-reaper"; import { getOrCreatePaperclipReactRoot } from "./lib/react-root"; import { startServiceWorkerUpdates } from "./lib/service-worker-updates"; @@ -35,6 +36,8 @@ initPluginBridge(React, ReactDOM); // accumulate into millions of native objects (GBs). Reap them periodically. startPerfMeasureReaper(); +startChunkLoadRecovery(); + // Parked SPA tabs never navigate, so beyond registering the worker this also // re-checks /sw.js on tab focus and hourly, and applies a discovered update // with one reload while the tab is hidden — otherwise an old worker and its