mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): auto-recover Control UI panels from stale hashed-chunk imports after gateway updates (#104305)
This commit is contained in:
committed by
GitHub
parent
7128e8ea2b
commit
c59d07b9a8
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user