fix(ui): auto-recover Control UI panels from stale hashed-chunk imports after gateway updates (#104305)

This commit is contained in:
Peter Steinberger
2026-07-11 01:12:16 -07:00
committed by GitHub
parent 7128e8ea2b
commit c59d07b9a8
5 changed files with 425 additions and 10 deletions
+49 -1
View File
@@ -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<typeof import("./stale-chunk-reload.ts")>();
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<void> {
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<RouteId, TestContext, TestModule, TestData>({
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<HTMLButtonElement>("button")?.click();
await settleOutlet(outlet);
expect(vi.mocked(retryStaleChunkReload)).toHaveBeenCalledTimes(1);
expect(loadCount).toBe(1);
outlet.remove();
router.stop();
});
});
+32 -9
View File
@@ -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<TRouteId extends string, TLoadContext, TModule, TData>(
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}
<div class="callout danger" role="alert">
<strong>${t("lazyView.errorTitle")}</strong>
<div>${routeError}</div>
<button
class="btn btn--sm"
@click=${() =>
retryContext === undefined
? undefined
: void router.revalidate(retryContext, routeId).catch(() => undefined)}
>
${t("lazyView.retry")}
</button>
${staleChunk ? html`<div>${t("lazyView.errorSubtitle")}</div>` : nothing}
<button class="btn btn--sm" @click=${handleRetry}>${t("lazyView.retry")}</button>
</div>
`;
}
+188
View File
@@ -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<string, string> = {}) {
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();
}
});
});
+154
View File
@@ -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<Storage, "getItem" | "setItem"> | null;
probeDocument?: () => Promise<boolean>;
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<Storage, "getItem" | "setItem"> | null {
try {
return window.sessionStorage;
} catch {
// Storage can be disabled; recovery then stays manual via the Retry button.
return null;
}
}
async function probeControlUiDocument(): Promise<boolean> {
try {
const response = await fetch(window.location.href, { method: "HEAD", cache: "no-store" });
return response.ok;
} catch {
return false;
}
}
function readGuardBuildId(storage: Pick<Storage, "getItem" | "setItem"> | null): string | null {
try {
return storage?.getItem(RELOAD_GUARD_STORAGE_KEY) ?? null;
} catch {
return null;
}
}
function persistGuardBuildId(
storage: Pick<Storage, "getItem" | "setItem"> | 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<boolean> {
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<boolean> {
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<boolean> = 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);
}
+2
View File
@@ -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);