Files
PaperClipAI/ui/src/components/RouteErrorBoundary.test.tsx
T
73526559b5 fix(ui): reload once when a lazy chunk or its CSS fails to load (#17349)
## 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 <origin>/assets/issue-detail-<hash>.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 `/<prefix>/agents`. The route error boundary shows "Unable to
preload CSS".
4. Open `/<prefix>/issues/<id>`. 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 <noreply@anthropic.com>
Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com>
2026-10-23 12:06:38 -07:00

212 lines
8.4 KiB
TypeScript

// @vitest-environment jsdom
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,
useNavigate: () => navigateMock,
}));
vi.mock("@/lib/sentry", () => ({
captureBrowserException: (...args: unknown[]) => captureBrowserExceptionMock(...args),
}));
const chunkRecovery = vi.hoisted(() => ({
current: null as null | ReturnType<typeof import("@/lib/chunk-load-recovery").createChunkLoadRecovery>,
}));
vi.mock("@/lib/chunk-load-recovery", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/chunk-load-recovery")>();
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");
}
describe("RouteErrorBoundary", () => {
let container: HTMLDivElement;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
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.
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
container.remove();
captureBrowserExceptionMock.mockClear();
});
it("keeps the surrounding shell mounted while a route loads", async () => {
let resolve!: (module: { default: () => React.JSX.Element }) => void;
const PendingPage = lazy(() => new Promise<{ default: () => React.JSX.Element }>((done) => { resolve = done; }));
const root = createRoot(container);
await act(async () => {
root.render(
<>
<nav>Task navigation</nav>
<RouteErrorBoundary><PendingPage /></RouteErrorBoundary>
</>,
);
});
const shell = container.querySelector("nav");
expect(container.querySelector('[role="status"]')?.textContent).toContain("Loading");
expect(shell?.textContent).toBe("Task navigation");
await act(async () => { resolve({ default: () => <div>Loaded page</div> }); });
expect(container.querySelector("nav")).toBe(shell);
expect(container.textContent).toContain("Loaded page");
expect(container.querySelector('[role="status"]')).toBeNull();
act(() => root.unmount());
});
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(<RouteErrorBoundary><FailedPage /></RouteErrorBoundary>); });
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(<RouteErrorBoundary><FailedPage /></RouteErrorBoundary>); });
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(<RouteErrorBoundary><AnotherFailedPage /></RouteErrorBoundary>); });
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(<RouteErrorBoundary><FailedPage /></RouteErrorBoundary>); });
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(<RouteErrorBoundary><div>Saved conversation</div></RouteErrorBoundary>); });
expect(container.textContent).toContain("Saved conversation");
expect(container.textContent).not.toContain("This page hit an error");
act(() => root.unmount());
});
it("navigates back when the user clicks Go back", () => {
const root = createRoot(container);
act(() => {
root.render(
<RouteErrorBoundary>
<Boom />
</RouteErrorBoundary>,
);
});
const goBack = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Go back",
);
expect(goBack).toBeDefined();
act(() => {
goBack?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(-1);
act(() => {
root.unmount();
});
});
it("recovers and renders children again after the route changes", () => {
const root = createRoot(container);
act(() => {
root.render(
<RouteErrorBoundary>
<Boom />
</RouteErrorBoundary>,
);
});
expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1);
expect(captureBrowserExceptionMock).toHaveBeenCalledWith(expect.any(Error), {
boundary: "route", componentStack: expect.stringContaining("Boom"),
});
expect(
Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent === "Go back",
),
).toBe(true);
expect(container.textContent).toContain("This page hit an error");
expect(container.textContent).toContain("Maximum update depth exceeded");
expect(container.querySelector("pre")).not.toBeNull();
// Simulate back-navigation to a different route, then re-render with a
// healthy child — the boundary should reset off the new resetKey.
routerLocation.current = { pathname: "/co/agents", search: "" };
act(() => {
root.render(
<RouteErrorBoundary>
<div>Agents list</div>
</RouteErrorBoundary>,
);
});
expect(container.textContent).toContain("Agents list");
expect(container.textContent).not.toContain("This page hit an error");
act(() => {
root.unmount();
});
});
});