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