Files
PaperClipAI/ui/src/components/RouteErrorBoundary.test.tsx
T
Devin FoleyandClaude Opus 5.5 434d224c3a fix(ui): retry stale chunk reloads while a deploy settles (#19361)
## 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. After a deploy, an
open tab can ask for a chunk from its old build.
> - #17349 added a recovery: the app reloads the page one time when a
chunk fails to load. A loop guard permits only one reload in five
minutes.
> - Staging error monitoring still reports `Failed to fetch dynamically
imported module` from builds that include #17349. The events come 1–10
seconds after page load, and 1–10 minutes after page load.
> - During a deploy, the old server and the new server can both answer
for some seconds. The reloaded page can then miss a chunk again. Also, a
second deploy can occur inside the five-minute window. In both cases the
guard stops the recovery, and the user sees the error page. One more
reload a few seconds later would recover.
> - #18518 keeps old chunks available after a deploy, but only on
deployments that enable shared UI assets. Other deployments still depend
on the reload recovery.
> - This pull request permits up to three reloads in the guard window.
The reloads wait 0 s, 2 s and 5 s, so that the deploy can settle. After
the last reload, the error page shows and reports as before.
> - The benefit is that a stale tab recovers through a normal deploy
without an error page and without error-monitoring noise. A chunk that
stays broken still shows the error after a small, fixed number of
reloads.

## Linked Issues or Issue Description

Refs #17349
Refs #18518

**What happened?**
A browser tab stayed open during a staging deploy. A lazy page chunk
failed to load with `TypeError: Failed to fetch dynamically imported
module`. The route error boundary showed "This page hit an error" and
reported the error. The release included the one-time reload from
#17349. The reload did not occur because the loop guard had already used
its single reload in the last five minutes.

**Expected behavior**
The app reloads again after a short wait while the deploy settles, and
the page loads. The error page shows only when the chunk stays broken.

**Steps to reproduce**
1. Open a board page on a deployment without shared UI assets.
2. Deploy a new build. While the old and new servers both answer, go to
a page that was not loaded yet.
3. The chunk fails, and the page reloads one time.
4. The reloaded page gets its HTML from one server and a chunk request
goes to the other server. The chunk fails again.
5. The guard stops the recovery, and the route error page shows.

**Paperclip version or commit**
`master` at 5ac05871b5

## What Changed

- `ui/src/lib/chunk-load-recovery.ts`: The recovery permits up to three
reloads in the five-minute guard window. The reloads wait 0 s, 2 s and 5
s (`RELOAD_DELAYS_MS`). After the third reload, the recovery returns
`false`, and the boundary shows and reports the error as before.
- Storage stays compatible with older builds.
`paperclip:chunk-load-reload-at` still holds only the bare start time of
the window, so an older build keeps its one-reload guard during a
deploy. The retry count is in a separate key,
`paperclip:chunk-load-reload-attempts` (`<window start>:<attempts>`).
The count applies only to the matching window. A window that an older
build started counts as one reload.
- A delayed retry keeps its timer. The new
`cancelPendingChunkLoadReload()` stops a retry that has not started.
`RouteErrorBoundary` calls it when the user moves to another route, and
when the boundary unmounts. A working page is therefore not reloaded
later.
- The abandonment timer starts after the delay, so a delayed reload gets
the full 10 s before the boundary reports.
- A reload that does not leave the document (for example, the user
cancels it) is not tried again from the same document. This keeps the
behavior from #17349.
- Tests: unit tests for delayed retries, the retry limit, compatibility
with older builds, a stale retry count, and cancellation. Boundary tests
start from a delayed attempt and cancel it on navigation. The e2e spec
for a lazy CSS asset that stays broken now expects 4 page loads before
the error page.

## Verification

- `cd ui && npx vitest run src/lib/chunk-load-recovery.test.ts
src/components/RouteErrorBoundary.test.tsx
src/components/AppErrorBoundary.test.tsx`: 3 files, 32 tests pass.
- Red before the fix: with the new tests, the first PR head fails 5
tests (older-build key, cancellation, cancel on navigation). The
original `master` code fails the retry tests. With this head, all pass.
- `tsc --noEmit -p ui/tsconfig.json` shows no errors in the changed
files.
- `tests/e2e/chunk-load-recovery.spec.ts` runs in CI.
- Manual check: on a deployment without shared UI assets, open a page,
deploy, and go to a page that was not loaded. The page reloads, and it
loads after the deploy settles. If you move to another page during the
wait, the page does not reload.

## Risks

- Low risk. The change affects only the chunk-load recovery path.
- A chunk that stays broken now causes up to three reloads (about 7 s of
waits in total) before the error page shows. Before, it caused one
reload. The limit stays fixed in each five-minute window, so a reload
loop cannot occur, also when old and new builds alternate.
- A retry that the `vite:preloadError` listener starts is not tied to a
route, so navigation does not cancel it. This matches the behavior
before this change, where that reload occurred at once.
- No server, data, or contract changes.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`) by Anthropic, used through Claude
Code with tool use (shell, file edits and GitHub connector). Standard
context window, with reasoning.

## 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>
2026-11-05 17:21:08 -08:00

264 lines
11 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, RELOAD_DELAYS_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),
cancelPendingChunkLoadReload: () => chunkRecovery.current!.cancelPendingChunkLoadReload(),
};
});
function seedReloadAttempts(attempts: number) {
const startedAt = Date.now();
window.sessionStorage.setItem("paperclip:chunk-load-reload-at", String(startedAt));
window.sessionStorage.setItem("paperclip:chunk-load-reload-attempts", `${startedAt}:${attempts}`);
}
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("keeps loading through a delayed retry, then reports once if it never leaves the page", async () => {
vi.useFakeTimers();
try {
seedReloadAttempts(1);
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>); });
const delayMs = RELOAD_DELAYS_MS[1]!;
act(() => { vi.advanceTimersByTime(delayMs - 1); });
expect(reloadMock).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(1); });
expect(reloadMock).toHaveBeenCalledTimes(1);
act(() => { vi.advanceTimersByTime(RELOAD_ABANDONED_MS - 1); });
expect(container.querySelector('[role="status"]')?.textContent).toContain("Loading");
expect(captureBrowserExceptionMock).not.toHaveBeenCalled();
act(() => { vi.advanceTimersByTime(1); });
expect(container.textContent).toContain("This page hit an error");
expect(captureBrowserExceptionMock).toHaveBeenCalledTimes(1);
act(() => root.unmount());
} finally {
vi.useRealTimers();
}
});
it("cancels a delayed retry when the user navigates away from the failed route", async () => {
vi.useFakeTimers();
try {
seedReloadAttempts(1);
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>); });
routerLocation.current = { pathname: "/co/issues/CO-1", search: "" };
await act(async () => { root.render(<RouteErrorBoundary><div>Saved conversation</div></RouteErrorBoundary>); });
act(() => { vi.advanceTimersByTime(RELOAD_DELAYS_MS[1]! + RELOAD_ABANDONED_MS); });
expect(reloadMock).not.toHaveBeenCalled();
expect(captureBrowserExceptionMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Saved conversation");
act(() => root.unmount());
} finally {
vi.useRealTimers();
}
});
it("contains a repeated chunk load failure and recovers when navigating back to a task", async () => {
seedReloadAttempts(RELOAD_DELAYS_MS.length);
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();
});
});
});