From 7c1c4960c1f4aab65bb7e616eb533cdcfe68f362 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 18 Jul 2026 08:33:29 +0100 Subject: [PATCH] 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. --- ui/src/app/stale-chunk-reload.test.ts | 136 ++++++++++++++++++++++++++ ui/src/app/stale-chunk-reload.ts | 115 +++++++++++++++++++++- ui/src/main.ts | 6 +- ui/src/styles.css | 6 ++ 4 files changed, 261 insertions(+), 2 deletions(-) diff --git a/ui/src/app/stale-chunk-reload.test.ts b/ui/src/app/stale-chunk-reload.test.ts index 8d24d389b49f..94606d6a256e 100644 --- a/ui/src/app/stale-chunk-reload.test.ts +++ b/ui/src/app/stale-chunk-reload.test.ts @@ -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 = {}) { 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('[role="alert"]'); + const reloadButton = banner?.querySelector("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(); + } + }); +}); diff --git a/ui/src/app/stale-chunk-reload.ts b/ui/src/app/stale-chunk-reload.ts index 033af7e56f13..1f0678c58111 100644 --- a/ui/src/app/stale-chunk-reload.ts +++ b/ui/src/app/stale-chunk-reload.ts @@ -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; + retry?: () => Promise; +}; + const lastAttemptAtByStorage = new WeakMap(); let lastAttemptWithoutStorage: number | null = null; let inFlightDocumentProbe: Promise | 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; + }; +} diff --git a/ui/src/main.ts b/ui/src/main.ts index d70ba0c7c3ff..33b27250302e 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -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); diff --git a/ui/src/styles.css b/ui/src/styles.css index 1c4b32d29c86..bd5f049fa02e 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -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);