mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
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>
This commit is contained in:
1 parent
9ceaec6f97
commit
434d224c3a
6 files changed
+233
-25
No files matched your search
@@ -45,7 +45,7 @@ test("a failed lazy CSS preload recovers with one reload", async ({ page, reques
|
||||
expect(counts.documents).toBe(2);
|
||||
});
|
||||
|
||||
test("a lazy CSS asset that stays broken reloads once, then shows the error", async ({ page, request }) => {
|
||||
test("a lazy CSS asset that stays broken reloads up to the retry limit, then shows the error", async ({ page, request }) => {
|
||||
const company = await json(await request.post("/api/companies", {
|
||||
data: { name: `Chunk recovery ${randomUUID()}` },
|
||||
}));
|
||||
@@ -61,9 +61,10 @@ test("a lazy CSS asset that stays broken reloads once, then shows the error", as
|
||||
|
||||
await page.goto(`/${company.issuePrefix}${environmentsPath}`);
|
||||
|
||||
await expect(page.getByText("This page hit an error")).toBeVisible();
|
||||
// Three reloads wait 0 s, 2 s and 5 s before the error page shows.
|
||||
await expect(page.getByText("This page hit an error")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText(/Unable to preload CSS for .*CompanyEnvironments-/)).toBeVisible();
|
||||
await page.waitForTimeout(1_000);
|
||||
expect(documents).toBe(2);
|
||||
expect(failed).toBe(2);
|
||||
expect(documents).toBe(4);
|
||||
expect(failed).toBe(4);
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { createChunkLoadRecovery, RELOAD_ABANDONED_MS, RELOAD_DELAYS_MS } from "@/lib/chunk-load-recovery";
|
||||
import { AppErrorBoundary } from "./AppErrorBoundary";
|
||||
|
||||
const captureBrowserExceptionMock = vi.hoisted(() => vi.fn());
|
||||
@@ -23,6 +23,7 @@ vi.mock("@/lib/chunk-load-recovery", async (importOriginal) => {
|
||||
...actual,
|
||||
reloadForChunkLoadError: (error: unknown) => chunkRecovery.current!.reloadForChunkLoadError(error),
|
||||
onChunkLoadReloadAbandoned: (listener: () => void) => chunkRecovery.current!.onChunkLoadReloadAbandoned(listener),
|
||||
cancelPendingChunkLoadReload: () => chunkRecovery.current!.cancelPendingChunkLoadReload(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -86,7 +87,7 @@ describe("AppErrorBoundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads once for a failed lazy chunk, then shows the prompt if it fails again", async () => {
|
||||
it("reloads for a failed lazy chunk, then shows the prompt once the retries are used up", 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(); });
|
||||
@@ -98,6 +99,11 @@ describe("AppErrorBoundary", () => {
|
||||
expect(container.querySelector('[role="status"]')?.textContent).toContain("Reloading Paperclip");
|
||||
act(() => root.unmount());
|
||||
|
||||
// The reloaded pages keep failing, as when an asset stays broken.
|
||||
for (let attempt = 1; attempt < RELOAD_DELAYS_MS.length; attempt += 1) {
|
||||
expect(createChunkLoadRecovery({ reload: vi.fn() }).reloadForChunkLoadError(chunkError())).toBe(true);
|
||||
}
|
||||
|
||||
chunkRecovery.current = createChunkLoadRecovery({ reload: reloadMock });
|
||||
const nextRoot = createRoot(container);
|
||||
const StillFailedLayout = lazy(async () => { throw chunkError(); });
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { createChunkLoadRecovery, RELOAD_ABANDONED_MS, RELOAD_DELAYS_MS } from "@/lib/chunk-load-recovery";
|
||||
import { RouteErrorBoundary } from "./RouteErrorBoundary";
|
||||
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
@@ -30,9 +30,16 @@ vi.mock("@/lib/chunk-load-recovery", async (importOriginal) => {
|
||||
...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");
|
||||
}
|
||||
@@ -125,8 +132,53 @@ describe("RouteErrorBoundary", () => {
|
||||
}
|
||||
});
|
||||
|
||||
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 () => {
|
||||
window.sessionStorage.setItem("paperclip:chunk-load-reload-at", String(Date.now()));
|
||||
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>); });
|
||||
|
||||
@@ -2,7 +2,11 @@ import { describeError } from "@/api/errors";
|
||||
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 {
|
||||
cancelPendingChunkLoadReload,
|
||||
onChunkLoadReloadAbandoned,
|
||||
reloadForChunkLoadError,
|
||||
} from "@/lib/chunk-load-recovery";
|
||||
import { captureBrowserException } from "@/lib/sentry";
|
||||
import { PaperclipLoading } from "./AnimatedPaperclipIcon";
|
||||
|
||||
@@ -40,9 +44,15 @@ class RouteErrorBoundaryInner extends Component<RouteErrorBoundaryInnerProps, Ro
|
||||
}
|
||||
|
||||
override componentWillUnmount(): void {
|
||||
this.cancelPendingReload();
|
||||
this.stopWaiting();
|
||||
}
|
||||
|
||||
// A delayed retry must not reload a page the user has since moved to.
|
||||
private cancelPendingReload(): void {
|
||||
if (this.state.reloading) cancelPendingChunkLoadReload();
|
||||
}
|
||||
|
||||
private stopWaiting(): void {
|
||||
this.stopWaitingForReload?.();
|
||||
this.stopWaitingForReload = null;
|
||||
@@ -58,6 +68,7 @@ class RouteErrorBoundaryInner extends Component<RouteErrorBoundaryInnerProps, Ro
|
||||
// away (back button included) can't recover without a hard refresh. We sit
|
||||
// above the routed <Outlet />, so reset whenever the route changes.
|
||||
if (this.state.error && prevProps.resetKey !== this.props.resetKey) {
|
||||
this.cancelPendingReload();
|
||||
this.stopWaiting();
|
||||
this.setState({ error: null, reloading: false });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createChunkLoadRecovery, isChunkLoadError, RELOAD_ABANDONED_MS } from "./chunk-load-recovery";
|
||||
import { createChunkLoadRecovery, isChunkLoadError, RELOAD_ABANDONED_MS, RELOAD_DELAYS_MS } from "./chunk-load-recovery";
|
||||
|
||||
function memoryStorage(initial: Record<string, string> = {}) {
|
||||
const values = new Map(Object.entries(initial));
|
||||
@@ -47,15 +47,98 @@ describe("reloadForChunkLoadError", () => {
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("defers to the error UI when the fresh page fails again", () => {
|
||||
it("retries a fresh page that fails again, waiting longer each time for the deploy to settle", () => {
|
||||
vi.useFakeTimers();
|
||||
const storage = memoryStorage();
|
||||
const reload = vi.fn();
|
||||
let now = 1_000_000;
|
||||
createChunkLoadRecovery({ storage: () => storage, reload, now: () => now }).reloadForChunkLoadError(cssPreloadError);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (const delayMs of RELOAD_DELAYS_MS.slice(1)) {
|
||||
now += 10_000;
|
||||
const freshPage = createChunkLoadRecovery({ storage: () => storage, reload, now: () => now });
|
||||
const reloadsBefore = reload.mock.calls.length;
|
||||
expect(freshPage.reloadForChunkLoadError(cssPreloadError)).toBe(true);
|
||||
vi.advanceTimersByTime(delayMs - 1);
|
||||
expect(reload).toHaveBeenCalledTimes(reloadsBefore);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(reload).toHaveBeenCalledTimes(reloadsBefore + 1);
|
||||
}
|
||||
});
|
||||
|
||||
it("defers to the error UI once the retries in the guard window are used up", () => {
|
||||
const storage = memoryStorage();
|
||||
const reload = vi.fn();
|
||||
let now = 1_000_000;
|
||||
for (let attempt = 0; attempt < RELOAD_DELAYS_MS.length; attempt += 1) {
|
||||
expect(createChunkLoadRecovery({ storage: () => storage, reload, now: () => now }).reloadForChunkLoadError(cssPreloadError)).toBe(true);
|
||||
now += 10_000;
|
||||
}
|
||||
|
||||
const exhaustedPage = createChunkLoadRecovery({ storage: () => storage, reload, now: () => now });
|
||||
expect(exhaustedPage.reloadForChunkLoadError(cssPreloadError)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a reload recorded by an older build as the first retry", () => {
|
||||
vi.useFakeTimers();
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage({ "paperclip:chunk-load-reload-at": "1000000" });
|
||||
const { reloadForChunkLoadError } = createChunkLoadRecovery({ storage: () => storage, reload, now: () => 1_010_000 });
|
||||
|
||||
expect(reloadForChunkLoadError(cssPreloadError)).toBe(true);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(RELOAD_DELAYS_MS[1]!);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the stored reload time readable by builds without retries", () => {
|
||||
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);
|
||||
createChunkLoadRecovery({ storage: () => storage, reload, now: () => now }).reloadForChunkLoadError(cssPreloadError);
|
||||
|
||||
// An older build reads this key with Number() and keeps its one-reload guard.
|
||||
expect(Number(storage.getItem("paperclip:chunk-load-reload-at"))).toBe(1_000_000);
|
||||
});
|
||||
|
||||
it("ignores a retry count left from another guard window", () => {
|
||||
vi.useFakeTimers();
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage({
|
||||
"paperclip:chunk-load-reload-at": "2000000",
|
||||
"paperclip:chunk-load-reload-attempts": `1000000:${RELOAD_DELAYS_MS.length}`,
|
||||
});
|
||||
const { reloadForChunkLoadError } = createChunkLoadRecovery({ storage: () => storage, reload, now: () => 2_010_000 });
|
||||
|
||||
expect(reloadForChunkLoadError(cssPreloadError)).toBe(true);
|
||||
vi.advanceTimersByTime(RELOAD_DELAYS_MS[1]!);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cancels a delayed retry that has not started", () => {
|
||||
vi.useFakeTimers();
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage({ "paperclip:chunk-load-reload-at": "1000000" });
|
||||
const recovery = createChunkLoadRecovery({ storage: () => storage, reload, now: () => 1_010_000 });
|
||||
expect(recovery.reloadForChunkLoadError(cssPreloadError)).toBe(true);
|
||||
const abandoned = vi.fn();
|
||||
recovery.onChunkLoadReloadAbandoned(abandoned);
|
||||
|
||||
expect(recovery.cancelPendingChunkLoadReload()).toBe(true);
|
||||
vi.advanceTimersByTime(RELOAD_DELAYS_MS[1]! + RELOAD_ABANDONED_MS);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(abandoned).not.toHaveBeenCalled();
|
||||
expect(recovery.cancelPendingChunkLoadReload()).toBe(false);
|
||||
});
|
||||
|
||||
it("cannot cancel a reload that already started", () => {
|
||||
const reload = vi.fn();
|
||||
const recovery = createChunkLoadRecovery({ storage: () => memoryStorage(), reload });
|
||||
expect(recovery.reloadForChunkLoadError(cssPreloadError)).toBe(true);
|
||||
expect(recovery.cancelPendingChunkLoadReload()).toBe(false);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -101,10 +184,13 @@ describe("reloadForChunkLoadError", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
for (const marker of ["not-a-time", "{\"windowStartedAt\":\"soon\"}", "null"]) {
|
||||
const reload = vi.fn();
|
||||
const storage = memoryStorage({ "paperclip:chunk-load-reload-at": marker });
|
||||
const { reloadForChunkLoadError } = createChunkLoadRecovery({ storage: () => storage, reload, now: () => 1_000 });
|
||||
expect(reloadForChunkLoadError(cssPreloadError)).toBe(true);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("never reloads for other errors", () => {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// Holds the bare start time of the guard window. Builds without retries read
|
||||
// this key too, so it keeps their single-reload guard working during a deploy.
|
||||
const RELOAD_AT_KEY = "paperclip:chunk-load-reload-at";
|
||||
// Holds "<window start>:<attempts>"; it only counts for the matching window.
|
||||
const RELOAD_ATTEMPTS_KEY = "paperclip:chunk-load-reload-attempts";
|
||||
const RELOAD_GUARD_MS = 5 * 60 * 1000;
|
||||
export const RELOAD_ABANDONED_MS = 10_000;
|
||||
// During a deploy, old and new servers can both answer for a few seconds, so a
|
||||
// freshly reloaded page can still miss a chunk. Each retry in the guard window
|
||||
// waits longer for the rollout to settle; after the last one the error shows.
|
||||
export const RELOAD_DELAYS_MS = [0, 2_000, 5_000];
|
||||
|
||||
const CHUNK_LOAD_ERROR_PATTERNS = [
|
||||
/^Unable to preload CSS for /,
|
||||
@@ -16,6 +24,17 @@ export function isChunkLoadError(error: unknown): boolean {
|
||||
|
||||
type ReloadStorage = Pick<Storage, "getItem" | "setItem">;
|
||||
|
||||
function isPositiveTime(value: number): boolean {
|
||||
return Number.isFinite(value) && value > 0;
|
||||
}
|
||||
|
||||
function readAttempts(raw: string | null, windowStartedAt: number): number {
|
||||
const [startedAt, attempts] = (raw ?? "").split(":").map(Number);
|
||||
// A window started by an older build has no count yet; its reload was the first.
|
||||
if (startedAt !== windowStartedAt || !Number.isInteger(attempts) || attempts! < 1) return 1;
|
||||
return attempts!;
|
||||
}
|
||||
|
||||
type ChunkLoadRecoveryOptions = {
|
||||
storage?: () => ReloadStorage | null;
|
||||
reload?: () => void;
|
||||
@@ -35,10 +54,17 @@ export function createChunkLoadRecovery(options: ChunkLoadRecoveryOptions = {})
|
||||
const reload = options.reload ?? (() => window.location.reload());
|
||||
const now = options.now ?? Date.now;
|
||||
let reloading = false;
|
||||
// A reload that never left this document (cancelled, blocked or stalled)
|
||||
// is not retried from the same document.
|
||||
let reloadAbandoned = false;
|
||||
let pendingReloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let abandonTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const abandonedListeners = new Set<() => void>();
|
||||
|
||||
function abandonReload() {
|
||||
abandonTimer = null;
|
||||
reloading = false;
|
||||
reloadAbandoned = true;
|
||||
const listeners = [...abandonedListeners];
|
||||
abandonedListeners.clear();
|
||||
for (const listener of listeners) listener();
|
||||
@@ -47,21 +73,46 @@ export function createChunkLoadRecovery(options: ChunkLoadRecoveryOptions = {})
|
||||
function reloadForChunkLoadError(error: unknown): boolean {
|
||||
if (!isChunkLoadError(error)) return false;
|
||||
if (reloading) return true;
|
||||
if (reloadAbandoned) return false;
|
||||
const storage = readStorage();
|
||||
if (!storage) return false;
|
||||
const at = now();
|
||||
let delayMs: number;
|
||||
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));
|
||||
const startedAt = Number(storage.getItem(RELOAD_AT_KEY));
|
||||
const inWindow = isPositiveTime(startedAt) && at - startedAt < RELOAD_GUARD_MS;
|
||||
const attempt = inWindow ? readAttempts(storage.getItem(RELOAD_ATTEMPTS_KEY), startedAt) : 0;
|
||||
if (attempt >= RELOAD_DELAYS_MS.length) return false;
|
||||
delayMs = RELOAD_DELAYS_MS[attempt]!;
|
||||
const windowStartedAt = inWindow ? startedAt : at;
|
||||
if (!inWindow) storage.setItem(RELOAD_AT_KEY, String(windowStartedAt));
|
||||
storage.setItem(RELOAD_ATTEMPTS_KEY, `${windowStartedAt}:${attempt + 1}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
reloading = true;
|
||||
reload();
|
||||
globalThis.setTimeout(abandonReload, RELOAD_ABANDONED_MS);
|
||||
if (delayMs > 0) {
|
||||
pendingReloadTimer = globalThis.setTimeout(() => {
|
||||
pendingReloadTimer = null;
|
||||
reload();
|
||||
}, delayMs);
|
||||
} else {
|
||||
reload();
|
||||
}
|
||||
abandonTimer = globalThis.setTimeout(abandonReload, delayMs + RELOAD_ABANDONED_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Stops a delayed retry that has not started yet, for example when the user
|
||||
// navigates away from the failed route. A started reload cannot be stopped.
|
||||
function cancelPendingChunkLoadReload(): boolean {
|
||||
if (pendingReloadTimer === null) return false;
|
||||
globalThis.clearTimeout(pendingReloadTimer);
|
||||
pendingReloadTimer = null;
|
||||
if (abandonTimer !== null) globalThis.clearTimeout(abandonTimer);
|
||||
abandonTimer = null;
|
||||
reloading = false;
|
||||
abandonedListeners.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -84,11 +135,12 @@ export function createChunkLoadRecovery(options: ChunkLoadRecoveryOptions = {})
|
||||
return () => target.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}
|
||||
|
||||
return { reloadForChunkLoadError, onChunkLoadReloadAbandoned, startChunkLoadRecovery };
|
||||
return { reloadForChunkLoadError, cancelPendingChunkLoadReload, onChunkLoadReloadAbandoned, startChunkLoadRecovery };
|
||||
}
|
||||
|
||||
const browserRecovery = createChunkLoadRecovery();
|
||||
|
||||
export const reloadForChunkLoadError = browserRecovery.reloadForChunkLoadError;
|
||||
export const cancelPendingChunkLoadReload = browserRecovery.cancelPendingChunkLoadReload;
|
||||
export const onChunkLoadReloadAbandoned = browserRecovery.onChunkLoadReloadAbandoned;
|
||||
export const startChunkLoadRecovery = browserRecovery.startChunkLoadRecovery;
|
||||
Reference in new issue
Block a user