diff --git a/ui/src/app/router-outlet.test.ts b/ui/src/app/router-outlet.test.ts index dee222ba1a0a..e85ba0f23906 100644 --- a/ui/src/app/router-outlet.test.ts +++ b/ui/src/app/router-outlet.test.ts @@ -1,8 +1,18 @@ import { createRouter, definePage, type Router } from "@openclaw/uirouter"; import { html, type LitElement } from "lit"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { retryStaleChunkReload, scheduleStaleChunkReload } from "./stale-chunk-reload.ts"; import "./router-outlet.ts"; +vi.mock("./stale-chunk-reload.ts", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + retryStaleChunkReload: vi.fn(async () => true), + scheduleStaleChunkReload: vi.fn(async () => false), + }; +}); + type RouteId = "page"; type TestContext = { label: string }; type TestData = { label: string }; @@ -47,6 +57,7 @@ async function settleOutlet(outlet: RouterOutletElement): Promise { afterEach(() => { document.body.replaceChildren(); + vi.clearAllMocks(); }); describe("openclaw-router-outlet", () => { @@ -117,4 +128,41 @@ describe("openclaw-router-outlet", () => { outlet.remove(); router.stop(); }); + + it("recovers stale-chunk import failures with a document reload instead of revalidate", async () => { + let loadCount = 0; + const router = createRouter({ + routes: [ + definePage({ + id: "page", + path: "/page", + component: () => Promise.reject(new Error("Importing a module script failed.")), + loader: (context) => { + loadCount += 1; + return { label: context.label }; + }, + }), + ], + }); + const context = { label: "stale" }; + const outlet = createOutlet(router, context); + + await expect(router.navigate("page", context)).rejects.toThrow( + "Importing a module script failed.", + ); + await settleOutlet(outlet); + + const alert = outlet.querySelector('[role="alert"]'); + expect(alert?.textContent).toContain("Importing a module script failed."); + expect(alert?.textContent).toContain("Reload the page"); + expect(vi.mocked(scheduleStaleChunkReload)).toHaveBeenCalled(); + + outlet.querySelector("button")?.click(); + await settleOutlet(outlet); + + expect(vi.mocked(retryStaleChunkReload)).toHaveBeenCalledTimes(1); + expect(loadCount).toBe(1); + outlet.remove(); + router.stop(); + }); }); diff --git a/ui/src/app/router-outlet.ts b/ui/src/app/router-outlet.ts index 0bbc2153367a..5a1107baa1f8 100644 --- a/ui/src/app/router-outlet.ts +++ b/ui/src/app/router-outlet.ts @@ -9,6 +9,11 @@ import { selectRenderedRouteMatch, type RouterOutletSnapshot, } from "./router-outlet-controller.ts"; +import { + isStaleChunkImportError, + retryStaleChunkReload, + scheduleStaleChunkReload, +} from "./stale-chunk-reload.ts"; export { selectRenderedRouteMatch } from "./router-outlet-controller.ts"; @@ -56,20 +61,38 @@ function renderError( render?: () => unknown, ) { const routeError = error instanceof Error ? error.message : String(error); + const staleChunk = isStaleChunkImportError(error); + if (staleChunk) { + // The chunk this document references was replaced by a newer build; + // revalidate cannot fix that, only a reload against the fresh index.html. + void scheduleStaleChunkReload(); + } + const revalidate = () => { + if (retryContext === undefined) { + return; + } + void router.revalidate(retryContext, routeId).catch(() => undefined); + }; + const handleRetry = () => { + if (!staleChunk) { + revalidate(); + return; + } + // Reload only when the gateway is reachable; during a restart fall back to + // revalidation so the panel error stays recoverable inside app webviews. + void retryStaleChunkReload().then((reloading) => { + if (!reloading) { + revalidate(); + } + }); + }; return html` ${render?.() ?? nothing} `; } diff --git a/ui/src/app/stale-chunk-reload.test.ts b/ui/src/app/stale-chunk-reload.test.ts new file mode 100644 index 000000000000..93b4a5293e7e --- /dev/null +++ b/ui/src/app/stale-chunk-reload.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + installStaleChunkReloadListener, + isStaleChunkImportError, + resetStaleChunkReloadStateForTest, + retryStaleChunkReload, + scheduleStaleChunkReload, +} from "./stale-chunk-reload.ts"; + +const GUARD_KEY = "openclaw.controlUi.staleChunkReloadBuildId"; + +function memoryStorage(initial: Record = {}) { + const store = new Map(Object.entries(initial)); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + }; +} + +afterEach(() => { + resetStaleChunkReloadStateForTest(); +}); + +describe("isStaleChunkImportError", () => { + it.each([ + "Importing a module script failed.", + "Failed to fetch dynamically imported module: http://x/assets/usage-abc123.js", + "error loading dynamically imported module", + "Unable to preload CSS for /assets/usage-abc123.css", + ])("matches module import failures: %s", (message) => { + expect(isStaleChunkImportError(new Error(message))).toBe(true); + }); + + it("ignores unrelated errors and non-error values", () => { + expect(isStaleChunkImportError(new Error("request failed"))).toBe(false); + expect(isStaleChunkImportError("Importing a module script failed.")).toBe(false); + expect(isStaleChunkImportError(undefined)).toBe(false); + }); +}); + +describe("scheduleStaleChunkReload", () => { + it("reloads once the document probe succeeds and records the build guard", async () => { + const reload = vi.fn(); + const storage = memoryStorage(); + await expect( + scheduleStaleChunkReload({ + now: () => 1000, + buildId: "build-a", + storage, + probeDocument: async () => true, + reload, + }), + ).resolves.toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + expect(storage.getItem(GUARD_KEY)).toBe("build-a"); + }); + + it("never auto-reloads twice for the same build, but recovers on a newer build", async () => { + const reload = vi.fn(); + const storage = memoryStorage({ [GUARD_KEY]: "build-a" }); + await expect( + scheduleStaleChunkReload({ + now: () => 1000, + buildId: "build-a", + storage, + probeDocument: async () => true, + reload, + }), + ).resolves.toBe(false); + expect(reload).not.toHaveBeenCalled(); + resetStaleChunkReloadStateForTest(); + await expect( + scheduleStaleChunkReload({ + now: () => 2000, + buildId: "build-b", + storage, + probeDocument: async () => true, + reload, + }), + ).resolves.toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + expect(storage.getItem(GUARD_KEY)).toBe("build-b"); + }); + + it("does not reload or set the guard while the gateway is unreachable", async () => { + const reload = vi.fn(); + const storage = memoryStorage(); + await expect( + scheduleStaleChunkReload({ + now: () => 1000, + storage, + probeDocument: async () => false, + reload, + }), + ).resolves.toBe(false); + expect(reload).not.toHaveBeenCalled(); + expect(storage.getItem(GUARD_KEY)).toBeNull(); + }); + + it("does not auto-reload when the guard cannot be persisted", async () => { + const reload = vi.fn(); + await expect( + scheduleStaleChunkReload({ + now: () => 1000, + storage: null, + probeDocument: async () => true, + reload, + }), + ).resolves.toBe(false); + resetStaleChunkReloadStateForTest(); + await expect( + scheduleStaleChunkReload({ + now: () => 1000, + storage: { + getItem: () => null, + setItem: () => { + throw new Error("quota exceeded"); + }, + }, + probeDocument: async () => true, + reload, + }), + ).resolves.toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); + + it("applies an in-memory cooldown between attempts", async () => { + const reload = vi.fn(); + const probeDocument = vi.fn(async () => true); + const storage = memoryStorage(); + await expect( + scheduleStaleChunkReload({ + now: () => 1000, + storage, + probeDocument: async () => false, + reload, + }), + ).resolves.toBe(false); + await expect( + scheduleStaleChunkReload({ now: () => 2000, storage, probeDocument, reload }), + ).resolves.toBe(false); + expect(probeDocument).not.toHaveBeenCalled(); + await expect( + scheduleStaleChunkReload({ now: () => 7000, storage, probeDocument, reload }), + ).resolves.toBe(true); + expect(reload).toHaveBeenCalledTimes(1); + }); +}); + +describe("retryStaleChunkReload", () => { + it("reloads without the rate guard when the gateway is reachable", async () => { + const reload = vi.fn(); + await expect(retryStaleChunkReload({ probeDocument: async () => true, reload })).resolves.toBe( + true, + ); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("does not reload while the gateway is unreachable", async () => { + const reload = vi.fn(); + await expect(retryStaleChunkReload({ probeDocument: async () => false, reload })).resolves.toBe( + false, + ); + expect(reload).not.toHaveBeenCalled(); + }); +}); + +describe("installStaleChunkReloadListener", () => { + function dispatchPreloadError(payload: unknown) { + const event = new Event("vite:preloadError", { cancelable: true }); + (event as Event & { payload?: unknown }).payload = payload; + window.dispatchEvent(event); + } + + it("schedules recovery only for stale-chunk payloads", () => { + const schedule = vi.fn(async () => false); + const uninstall = installStaleChunkReloadListener(schedule); + try { + dispatchPreloadError(new Error("boom in module evaluation")); + expect(schedule).not.toHaveBeenCalled(); + + dispatchPreloadError(new Error("Importing a module script failed.")); + expect(schedule).toHaveBeenCalledTimes(1); + } finally { + uninstall(); + } + }); +}); diff --git a/ui/src/app/stale-chunk-reload.ts b/ui/src/app/stale-chunk-reload.ts new file mode 100644 index 000000000000..9e4bbf15ffbc --- /dev/null +++ b/ui/src/app/stale-chunk-reload.ts @@ -0,0 +1,154 @@ +// Stale hashed-chunk recovery for lazy routes. +// +// A gateway update replaces `ui/dist` in place, so a document loaded before the +// update still references the old hashed chunk URLs; the first visit to a lazy +// route after the update 404s and the dynamic import rejects ("Importing a +// module script failed"). Secure-context browsers recover through the service +// worker registered in main.ts (prior-build chunk caches + reload broadcast), +// but WKWebView (macOS/iOS apps) and plain-HTTP LAN origins never register a +// service worker, so reloading against the freshly served index.html is the +// only recovery path there. +import { CONTROL_UI_BUILD_INFO } from "../build-info.ts"; + +const RELOAD_GUARD_STORAGE_KEY = "openclaw.controlUi.staleChunkReloadBuildId"; +// Bounds document probes across rapid re-renders of the same error state. +const ATTEMPT_COOLDOWN_MS = 5_000; + +const MODULE_IMPORT_ERROR_PATTERNS = [ + /importing a module script failed/i, // WebKit + /failed to fetch dynamically imported module/i, // Chromium + /error loading dynamically imported module/i, // Firefox + /unable to preload css/i, // Vite preload helper +]; + +type StaleChunkReloadDeps = { + now?: () => number; + buildId?: string; + storage?: Pick | null; + probeDocument?: () => Promise; + reload?: () => void; +}; + +let lastAttemptAt: number | null = null; + +export function isStaleChunkImportError(error: unknown): boolean { + return ( + error instanceof Error && + MODULE_IMPORT_ERROR_PATTERNS.some((pattern) => pattern.test(error.message)) + ); +} + +export function reloadControlUiDocument(): void { + window.location.reload(); +} + +function sessionStorageOrNull(): Pick | null { + try { + return window.sessionStorage; + } catch { + // Storage can be disabled; recovery then stays manual via the Retry button. + return null; + } +} + +async function probeControlUiDocument(): Promise { + try { + const response = await fetch(window.location.href, { method: "HEAD", cache: "no-store" }); + return response.ok; + } catch { + return false; + } +} + +function readGuardBuildId(storage: Pick | null): string | null { + try { + return storage?.getItem(RELOAD_GUARD_STORAGE_KEY) ?? null; + } catch { + return null; + } +} + +function persistGuardBuildId( + storage: Pick | null, + buildId: string, +): boolean { + if (!storage) { + return false; + } + try { + storage.setItem(RELOAD_GUARD_STORAGE_KEY, buildId); + return true; + } catch { + return false; + } +} + +/** + * Reload the document so stale hashed chunks resolve against the freshly + * served index.html. Returns whether a reload was initiated. Reloads only when + * the gateway answers a document probe — while it is restarting, a reload + * would replace the whole document with a navigation error (fatal inside the + * app webviews) instead of the recoverable panel error. + */ +export async function scheduleStaleChunkReload(deps: StaleChunkReloadDeps = {}): Promise { + const now = deps.now?.() ?? Date.now(); + if (lastAttemptAt !== null && now - lastAttemptAt < ATTEMPT_COOLDOWN_MS) { + return false; + } + lastAttemptAt = now; + const storage = deps.storage === undefined ? sessionStorageOrNull() : deps.storage; + const buildId = deps.buildId ?? CONTROL_UI_BUILD_INFO.buildId; + // One automatic reload per build id: if the reloaded document still fails + // with the same build, the build itself is broken and reloading cannot help. + // A genuinely newer deployment ships a new build id and may recover again. + if (readGuardBuildId(storage) === buildId) { + return false; + } + if (!(await (deps.probeDocument ?? probeControlUiDocument)())) { + return false; + } + // A reload resets the in-memory state, so without a persisted guard a broken + // build would reload forever. When storage is unavailable or rejects the + // write, leave recovery to the manual Retry path instead of reloading. + if (!persistGuardBuildId(storage, buildId)) { + return false; + } + (deps.reload ?? reloadControlUiDocument)(); + return true; +} + +/** + * User-initiated retry: bypasses the automatic-reload rate guard but keeps the + * reachability probe — reloading against an unreachable gateway replaces the + * recoverable panel error with a fatal navigation error in app webviews. + */ +export async function retryStaleChunkReload(deps: StaleChunkReloadDeps = {}): Promise { + if (!(await (deps.probeDocument ?? probeControlUiDocument)())) { + return false; + } + (deps.reload ?? reloadControlUiDocument)(); + return true; +} + +export function resetStaleChunkReloadStateForTest(): void { + lastAttemptAt = null; +} + +/** + * Vite dispatches `vite:preloadError` for every lazy-import rejection, + * including ordinary module evaluation errors — reload only for recognized + * stale-asset failures so a plain code bug cannot trigger a reload loop. + */ +export function installStaleChunkReloadListener( + schedule: (deps?: StaleChunkReloadDeps) => Promise = scheduleStaleChunkReload, +): () => void { + const onPreloadError = (event: Event) => { + const payload = (event as Event & { payload?: unknown }).payload; + if (!isStaleChunkImportError(payload)) { + return; + } + void schedule(); + }; + window.addEventListener("vite:preloadError", onPreloadError); + return () => window.removeEventListener("vite:preloadError", onPreloadError); +} diff --git a/ui/src/main.ts b/ui/src/main.ts index 2fbd5412d944..d70ba0c7c3ff 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -2,6 +2,7 @@ import "./styles.css"; import "./app/app-host.ts"; import { inferControlUiPublicAssetPath } from "./app/public-assets.ts"; +import { installStaleChunkReloadListener } from "./app/stale-chunk-reload.ts"; import { CONTROL_UI_BUILD_INFO } from "./build-info.ts"; type ViteImportMeta = ImportMeta & { @@ -14,6 +15,7 @@ const isProd = (import.meta as ViteImportMeta).env?.PROD === true; const currentControlUiBuildId = CONTROL_UI_BUILD_INFO.buildId; syncDocumentPublicAssetLinks(); +installStaleChunkReloadListener(); if (isProd && "serviceWorker" in navigator) { const swUrl = new URL(inferControlUiPublicAssetPath("sw.js"), window.location.origin);