mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 00:41:36 +00:00
fix(ui): detect failed entry-stylesheet loads and self-heal with a reload (#110523)
A gateway in-place update can leave a document whose hashed entry CSS 404s while the app JS still boots, rendering a fully unstyled page that no existing safety net catches (vite:preloadError only covers dynamic imports; the index.html mount fallback only checks the custom element). Add a CSS sentinel (--openclaw-css-ok) checked at document load plus a capture-phase stylesheet error listener; on detection reuse the existing stale-chunk auto-reload (one per build id, gateway probe), and fall back to an inline-styled banner with a Reload button when auto-reload is unavailable.
This commit is contained in:
committed by
GitHub
parent
d7de67ae02
commit
7c1c4960c1
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installMissingStylesheetRecovery,
|
||||
installStaleChunkReloadListener,
|
||||
isStaleChunkImportError,
|
||||
retryStaleChunkReload,
|
||||
@@ -63,7 +64,9 @@ function memoryStorage(initial: Record<string, string> = {}) {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("isStaleChunkImportError", () => {
|
||||
@@ -265,3 +268,136 @@ describe("installStaleChunkReloadListener", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("installMissingStylesheetRecovery", () => {
|
||||
function setReadyState(readyState: DocumentReadyState) {
|
||||
return vi.spyOn(document, "readyState", "get").mockReturnValue(readyState);
|
||||
}
|
||||
|
||||
function dispatchStylesheetError() {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
document.head.append(link);
|
||||
link.dispatchEvent(new Event("error"));
|
||||
link.remove();
|
||||
}
|
||||
|
||||
it("does nothing when the stylesheet sentinel is present and removes listeners", () => {
|
||||
setReadyState("complete");
|
||||
const schedule = vi.fn(async () => false);
|
||||
const uninstall = installMissingStylesheetRecovery({
|
||||
isCssApplied: () => true,
|
||||
schedule,
|
||||
});
|
||||
try {
|
||||
window.dispatchEvent(new Event("load"));
|
||||
dispatchStylesheetError();
|
||||
expect(schedule).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
|
||||
it("schedules recovery when the sentinel is missing at load", async () => {
|
||||
setReadyState("loading");
|
||||
const schedule = vi.fn(async () => true);
|
||||
const uninstall = installMissingStylesheetRecovery({
|
||||
isCssApplied: () => false,
|
||||
schedule,
|
||||
});
|
||||
try {
|
||||
window.dispatchEvent(new Event("load"));
|
||||
await Promise.resolve();
|
||||
expect(schedule).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a reload banner when automatic recovery is unavailable", async () => {
|
||||
setReadyState("complete");
|
||||
const retry = vi.fn(async () => false);
|
||||
const uninstall = installMissingStylesheetRecovery({
|
||||
isCssApplied: () => false,
|
||||
schedule: vi.fn(async () => false),
|
||||
retry,
|
||||
});
|
||||
try {
|
||||
await Promise.resolve();
|
||||
const banner = document.querySelector<HTMLElement>('[role="alert"]');
|
||||
const reloadButton = banner?.querySelector<HTMLButtonElement>("button");
|
||||
expect(banner?.textContent).toContain("Styles failed to load, so the page may look broken.");
|
||||
expect(reloadButton?.textContent).toBe("Reload");
|
||||
reloadButton?.click();
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
expect(banner?.isConnected).toBe(true);
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
|
||||
it("detects a capture-phase stylesheet error before load", async () => {
|
||||
setReadyState("loading");
|
||||
const schedule = vi.fn(async () => true);
|
||||
const uninstall = installMissingStylesheetRecovery({
|
||||
isCssApplied: () => true,
|
||||
schedule,
|
||||
});
|
||||
try {
|
||||
dispatchStylesheetError();
|
||||
await Promise.resolve();
|
||||
expect(schedule).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
|
||||
it("detects at most once when the resource error and load paths both fire", async () => {
|
||||
setReadyState("loading");
|
||||
const schedule = vi.fn(async () => true);
|
||||
const uninstall = installMissingStylesheetRecovery({
|
||||
isCssApplied: () => false,
|
||||
schedule,
|
||||
});
|
||||
try {
|
||||
dispatchStylesheetError();
|
||||
window.dispatchEvent(new Event("load"));
|
||||
await Promise.resolve();
|
||||
expect(schedule).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
|
||||
it("uninstall removes the banner and listeners", async () => {
|
||||
const readyState = setReadyState("loading");
|
||||
const listenerSchedule = vi.fn(async () => true);
|
||||
const uninstallListeners = installMissingStylesheetRecovery({
|
||||
isCssApplied: () => false,
|
||||
schedule: listenerSchedule,
|
||||
});
|
||||
uninstallListeners();
|
||||
dispatchStylesheetError();
|
||||
window.dispatchEvent(new Event("load"));
|
||||
expect(listenerSchedule).not.toHaveBeenCalled();
|
||||
|
||||
readyState.mockReturnValue("complete");
|
||||
const schedule = vi.fn(async () => false);
|
||||
const uninstall = installMissingStylesheetRecovery({
|
||||
isCssApplied: () => false,
|
||||
schedule,
|
||||
});
|
||||
try {
|
||||
await Promise.resolve();
|
||||
expect(document.querySelector('[role="alert"]')).not.toBeNull();
|
||||
|
||||
uninstall();
|
||||
expect(document.querySelector('[role="alert"]')).toBeNull();
|
||||
dispatchStylesheetError();
|
||||
window.dispatchEvent(new Event("load"));
|
||||
expect(schedule).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
uninstall();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Stale hashed-chunk recovery for lazy routes.
|
||||
// Stale hashed-chunk recovery for lazy routes and the entry stylesheet.
|
||||
//
|
||||
// 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
|
||||
@@ -31,6 +31,12 @@ type StaleChunkReloadDeps = {
|
||||
reload?: () => void;
|
||||
};
|
||||
|
||||
type MissingStylesheetRecoveryDeps = {
|
||||
isCssApplied?: () => boolean;
|
||||
schedule?: () => Promise<boolean>;
|
||||
retry?: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const lastAttemptAtByStorage = new WeakMap<object, number>();
|
||||
let lastAttemptWithoutStorage: number | null = null;
|
||||
let inFlightDocumentProbe: Promise<boolean> | null = null;
|
||||
@@ -179,3 +185,110 @@ export function installStaleChunkReloadListener(
|
||||
window.addEventListener("vite:preloadError", onPreloadError);
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}
|
||||
|
||||
export function installMissingStylesheetRecovery(
|
||||
deps: MissingStylesheetRecoveryDeps = {},
|
||||
): () => void {
|
||||
const isCssApplied =
|
||||
deps.isCssApplied ??
|
||||
(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--openclaw-css-ok").trim() ===
|
||||
"1");
|
||||
const schedule = deps.schedule ?? scheduleStaleChunkReload;
|
||||
const retry = deps.retry ?? retryStaleChunkReload;
|
||||
let detected = false;
|
||||
let uninstalled = false;
|
||||
let banner: HTMLDivElement | null = null;
|
||||
|
||||
const removeListeners = () => {
|
||||
window.removeEventListener("load", checkStylesheet);
|
||||
window.removeEventListener("error", onResourceError, true);
|
||||
};
|
||||
|
||||
const showBanner = () => {
|
||||
if (uninstalled || banner) {
|
||||
return;
|
||||
}
|
||||
banner = document.createElement("div");
|
||||
banner.setAttribute("role", "alert");
|
||||
// All styles are inline because the entry stylesheet is broken by definition.
|
||||
Object.assign(banner.style, {
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
right: "0",
|
||||
zIndex: "2147483647",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "12px",
|
||||
padding: "12px 16px",
|
||||
background: "#1f2937",
|
||||
color: "#ffffff",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
fontSize: "14px",
|
||||
});
|
||||
const message = document.createElement("span");
|
||||
// Intentional English: this failure surface mirrors the inline index.html fallback.
|
||||
message.textContent = "Styles failed to load, so the page may look broken.";
|
||||
const reloadButton = document.createElement("button");
|
||||
reloadButton.type = "button";
|
||||
reloadButton.textContent = "Reload";
|
||||
Object.assign(reloadButton.style, {
|
||||
border: "0",
|
||||
borderRadius: "4px",
|
||||
padding: "6px 12px",
|
||||
background: "#ffffff",
|
||||
color: "#111827",
|
||||
cursor: "pointer",
|
||||
font: "inherit",
|
||||
});
|
||||
reloadButton.addEventListener("click", () => void retry());
|
||||
banner.append(message, reloadButton);
|
||||
document.body.append(banner);
|
||||
};
|
||||
|
||||
const detectMissingStylesheet = async () => {
|
||||
if (detected || uninstalled) {
|
||||
return;
|
||||
}
|
||||
detected = true;
|
||||
removeListeners();
|
||||
const reloaded = await schedule();
|
||||
if (!reloaded) {
|
||||
showBanner();
|
||||
}
|
||||
};
|
||||
|
||||
function checkStylesheet() {
|
||||
if (isCssApplied()) {
|
||||
removeListeners();
|
||||
return;
|
||||
}
|
||||
void detectMissingStylesheet();
|
||||
}
|
||||
|
||||
function onResourceError(event: Event) {
|
||||
const resource = event.target;
|
||||
if (!(resource instanceof HTMLLinkElement) || !resource.relList.contains("stylesheet")) {
|
||||
return;
|
||||
}
|
||||
// Resource errors do not bubble, so capture is required. This can miss an
|
||||
// error fired before module evaluation; the load-time sentinel is authoritative.
|
||||
void detectMissingStylesheet();
|
||||
}
|
||||
|
||||
window.addEventListener("error", onResourceError, true);
|
||||
if (document.readyState === "complete") {
|
||||
checkStylesheet();
|
||||
} else {
|
||||
window.addEventListener("load", checkStylesheet, { once: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
uninstalled = true;
|
||||
removeListeners();
|
||||
banner?.remove();
|
||||
banner = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import "./styles.css";
|
||||
import "./app/app-host.ts";
|
||||
import { inferControlUiPublicAssetPath } from "./app/public-assets.ts";
|
||||
import { installStaleChunkReloadListener } from "./app/stale-chunk-reload.ts";
|
||||
import {
|
||||
installMissingStylesheetRecovery,
|
||||
installStaleChunkReloadListener,
|
||||
} from "./app/stale-chunk-reload.ts";
|
||||
import { CONTROL_UI_BUILD_INFO } from "./build-info.ts";
|
||||
|
||||
type ViteImportMeta = ImportMeta & {
|
||||
@@ -16,6 +19,7 @@ const currentControlUiBuildId = CONTROL_UI_BUILD_INFO.buildId;
|
||||
|
||||
syncDocumentPublicAssetLinks();
|
||||
installStaleChunkReloadListener();
|
||||
installMissingStylesheetRecovery();
|
||||
|
||||
if (isProd && "serviceWorker" in navigator) {
|
||||
const swUrl = new URL(inferControlUiPublicAssetPath("sw.js"), window.location.origin);
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
@import "./styles/lobster-pet.css";
|
||||
@import "@create-markdown/preview/themes/system.css";
|
||||
|
||||
/* Contract read by stale-chunk-reload.ts to detect a failed entry stylesheet load.
|
||||
* Removing it leaves a fully unstyled page undetected. */
|
||||
:root {
|
||||
--openclaw-css-ok: 1;
|
||||
}
|
||||
|
||||
.cm-preview {
|
||||
--cm-mono: var(--mono);
|
||||
--cm-link: var(--accent);
|
||||
|
||||
Reference in New Issue
Block a user