fix(ui): recover from stale Control UI bundles (#99111)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Wynne668
2026-07-06 05:26:49 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent a0e591c863
commit f36f3f30ea
3 changed files with 197 additions and 7 deletions
+79 -6
View File
@@ -209,14 +209,13 @@
<div class="mount-fallback__panel" tabindex="-1">
<p class="mount-fallback__eyebrow">OpenClaw Control UI</p>
<h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
<p>
The browser loaded the static page, but the app bundle did not register the
<code>openclaw-app</code> web component. A browser extension or early content script may
be blocking module execution.
<p id="openclaw-mount-fallback-summary">
The browser loaded the static page, but the app bundle did not start. The gateway may be
restarting, or this page may reference assets from a different OpenClaw version.
</p>
<ul>
<li>Try again in a clean browser profile or private window.</li>
<li>Disable extensions that run on all pages, then reload this dashboard.</li>
<li>OpenClaw will retry the current app bundle automatically.</li>
<li>If this persists, reload or try a clean browser profile.</li>
<li>
See
<a
@@ -249,11 +248,26 @@
if (!app || !fallback) return;
var panel = fallback.querySelector(".mount-fallback__panel");
var summary = document.getElementById("openclaw-mount-fallback-summary");
var retry = document.getElementById("openclaw-mount-retry");
var wait = document.getElementById("openclaw-mount-wait");
var rawDelay = Number(fallback.getAttribute("data-openclaw-mount-timeout-ms"));
var delay = Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 12000;
var maxRecoveryAttempts = 6;
var timer;
var recoveryTimer;
var recoveryAttempt = 0;
var recoveryInFlight = false;
var recoveryNavigation = false;
try {
var initialUrl = new URL(window.location.href);
recoveryNavigation = initialUrl.searchParams.has("openclaw_mount_recovery");
if (recoveryNavigation) {
initialUrl.searchParams.delete("openclaw_mount_recovery");
window.history.replaceState(null, "", initialUrl.href);
}
} catch (e) {}
function appMounted() {
try {
@@ -273,8 +287,64 @@
document.body.classList.remove("openclaw-mount-fallback-active");
}
function setSummary(text) {
if (summary) summary.textContent = text;
}
function scheduleRecovery() {
window.clearTimeout(recoveryTimer);
if (appMounted() || recoveryAttempt >= maxRecoveryAttempts) return;
var retryDelay = Math.min(delay, 1000 * Math.pow(2, recoveryAttempt));
recoveryTimer = window.setTimeout(retryCurrentDocument, retryDelay);
}
function finishRecoveryAttempt() {
recoveryInFlight = false;
if (appMounted()) return;
if (recoveryAttempt >= maxRecoveryAttempts) {
setSummary(
"The gateway is still unavailable. Try again, then check the troubleshooting guide if the problem persists.",
);
return;
}
setSummary(
"The gateway is not reachable yet. OpenClaw will keep retrying while it restarts.",
);
scheduleRecovery();
}
function retryCurrentDocument() {
if (
appMounted() ||
recoveryInFlight ||
recoveryAttempt >= maxRecoveryAttempts ||
typeof window.fetch !== "function"
)
return;
var documentUrl = new URL(window.location.href);
if (recoveryNavigation) {
setSummary(
"A fresh page still could not start the Control UI. Try again, then check the troubleshooting guide if the problem persists.",
);
return;
}
recoveryInFlight = true;
recoveryAttempt += 1;
documentUrl.searchParams.set("openclaw_mount_recovery", String(Date.now()));
window
.fetch(documentUrl.href, { cache: "no-store", credentials: "same-origin" })
.then(function (response) {
if (!response.ok) throw new Error("gateway unavailable");
window.location.replace(documentUrl.href);
})
.catch(function () {
finishRecoveryAttempt();
});
}
function showFallback() {
if (appMounted()) return;
retryCurrentDocument();
fallback.hidden = false;
document.body.classList.add("openclaw-mount-fallback-active");
if (panel && typeof panel.focus === "function") {
@@ -297,6 +367,7 @@
window.customElements.whenDefined(tagName).then(
function () {
window.clearTimeout(timer);
window.clearTimeout(recoveryTimer);
hideFallback();
},
function () {},
@@ -311,6 +382,8 @@
if (wait) {
wait.addEventListener("click", function () {
hideFallback();
recoveryAttempt = 0;
retryCurrentDocument();
armFallbackTimer();
});
}
+35 -1
View File
@@ -1,7 +1,7 @@
// Control UI tests cover mount fallback behavior.
import { readFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const indexHtmlPath = path.resolve(
process.cwd(),
@@ -115,4 +115,38 @@ describe("Control UI mount fallback", () => {
expect(fallback.hidden).toBe(true);
expect([...frameWindow.document.body.classList]).toEqual([]);
});
it("probes a cache-busted current document when the original bundle did not start", async () => {
const frameWindow = createIsolatedWindow();
const html = await readIndexHtmlWithDelay(1);
const fetch = vi.fn().mockResolvedValue({ ok: false });
Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
installFallbackShell(frameWindow, html);
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining("openclaw_mount_recovery="), {
cache: "no-store",
credentials: "same-origin",
});
});
it("bounds automatic recovery attempts while the gateway is unavailable", async () => {
const frameWindow = createIsolatedWindow();
const fetch = vi.fn().mockRejectedValue(new Error("gateway unavailable"));
Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(6));
await waitForWindowTimeout(frameWindow, 10);
expect(fetch).toHaveBeenCalledTimes(6);
expect(
requireElementById(
frameWindow,
"openclaw-mount-fallback-summary",
frameWindow.HTMLParagraphElement,
).textContent,
).toContain("gateway is still unavailable");
});
});
+83
View File
@@ -0,0 +1,83 @@
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
let browser: Browser;
let server: ControlUiE2eServer;
describeControlUiE2e("Control UI mount recovery E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
}
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("reloads a fresh document after the initial app module is unavailable", async () => {
const artifactDir = path.resolve(".artifacts/control-ui-e2e/mount-recovery");
const context = await browser.newContext({
locale: "en-US",
recordVideo: { dir: artifactDir, size: { height: 720, width: 1280 } },
serviceWorkers: "block",
viewport: { height: 720, width: 1280 },
});
const page = await context.newPage();
const baseUrl = new URL(server.baseUrl);
let documentRequests = 0;
let failedModuleRequests = 0;
await page.route(`${baseUrl.origin}/**`, async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.resourceType() === "document") {
documentRequests += 1;
const response = await route.fetch();
const body = (await response.text()).replace(
'data-openclaw-mount-timeout-ms="12000"',
'data-openclaw-mount-timeout-ms="50"',
);
await route.fulfill({ response, body });
return;
}
if (url.pathname === "/src/main.ts" && failedModuleRequests === 0) {
failedModuleRequests += 1;
await route.fulfill({ body: "gateway restarting", status: 503 });
return;
}
await route.continue();
});
await installMockGateway(page);
try {
expect(
(await page.goto(`${server.baseUrl}chat`, { waitUntil: "domcontentloaded" }))?.status(),
).toBe(200);
await page.locator("openclaw-app-shell").waitFor();
await page.locator(".agent-chat__welcome").waitFor();
expect(documentRequests).toBe(2);
expect(failedModuleRequests).toBe(1);
await expect.poll(() => page.url()).not.toContain("openclaw_mount_recovery");
await page.screenshot({ path: path.join(artifactDir, "recovered-control-ui.png") });
} finally {
await context.close();
}
});
});