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:
Peter Steinberger
2026-07-18 08:33:29 +01:00
committed by GitHub
parent d7de67ae02
commit 7c1c4960c1
4 changed files with 261 additions and 2 deletions

View File

@@ -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();
}
});
});

View File

@@ -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;
};
}

View File

@@ -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);

View File

@@ -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);