Skip to content

Commit cd2979e

Browse files
steipeteZengWen-DT
andcommitted
fix(ui): recover from stale Control UI bundles
Co-authored-by: ZengWen-DT <[email protected]>
1 parent bf04d04 commit cd2979e

3 files changed

Lines changed: 197 additions & 7 deletions

File tree

ui/index.html

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,13 @@
209209
<div class="mount-fallback__panel" tabindex="-1">
210210
<p class="mount-fallback__eyebrow">OpenClaw Control UI</p>
211211
<h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
212-
<p>
213-
The browser loaded the static page, but the app bundle did not register the
214-
<code>openclaw-app</code> web component. A browser extension or early content script may
215-
be blocking module execution.
212+
<p id="openclaw-mount-fallback-summary">
213+
The browser loaded the static page, but the app bundle did not start. The gateway may be
214+
restarting, or this page may reference assets from a different OpenClaw version.
216215
</p>
217216
<ul>
218-
<li>Try again in a clean browser profile or private window.</li>
219-
<li>Disable extensions that run on all pages, then reload this dashboard.</li>
217+
<li>OpenClaw will retry the current app bundle automatically.</li>
218+
<li>If this persists, reload or try a clean browser profile.</li>
220219
<li>
221220
See
222221
<a
@@ -249,11 +248,26 @@ <h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
249248
if (!app || !fallback) return;
250249

251250
var panel = fallback.querySelector(".mount-fallback__panel");
251+
var summary = document.getElementById("openclaw-mount-fallback-summary");
252252
var retry = document.getElementById("openclaw-mount-retry");
253253
var wait = document.getElementById("openclaw-mount-wait");
254254
var rawDelay = Number(fallback.getAttribute("data-openclaw-mount-timeout-ms"));
255255
var delay = Number.isFinite(rawDelay) && rawDelay > 0 ? rawDelay : 12000;
256+
var maxRecoveryAttempts = 6;
256257
var timer;
258+
var recoveryTimer;
259+
var recoveryAttempt = 0;
260+
var recoveryInFlight = false;
261+
var recoveryNavigation = false;
262+
263+
try {
264+
var initialUrl = new URL(window.location.href);
265+
recoveryNavigation = initialUrl.searchParams.has("openclaw_mount_recovery");
266+
if (recoveryNavigation) {
267+
initialUrl.searchParams.delete("openclaw_mount_recovery");
268+
window.history.replaceState(null, "", initialUrl.href);
269+
}
270+
} catch (e) {}
257271

258272
function appMounted() {
259273
try {
@@ -273,8 +287,64 @@ <h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
273287
document.body.classList.remove("openclaw-mount-fallback-active");
274288
}
275289

290+
function setSummary(text) {
291+
if (summary) summary.textContent = text;
292+
}
293+
294+
function scheduleRecovery() {
295+
window.clearTimeout(recoveryTimer);
296+
if (appMounted() || recoveryAttempt >= maxRecoveryAttempts) return;
297+
var retryDelay = Math.min(delay, 1000 * Math.pow(2, recoveryAttempt));
298+
recoveryTimer = window.setTimeout(retryCurrentDocument, retryDelay);
299+
}
300+
301+
function finishRecoveryAttempt() {
302+
recoveryInFlight = false;
303+
if (appMounted()) return;
304+
if (recoveryAttempt >= maxRecoveryAttempts) {
305+
setSummary(
306+
"The gateway is still unavailable. Try again, then check the troubleshooting guide if the problem persists.",
307+
);
308+
return;
309+
}
310+
setSummary(
311+
"The gateway is not reachable yet. OpenClaw will keep retrying while it restarts.",
312+
);
313+
scheduleRecovery();
314+
}
315+
316+
function retryCurrentDocument() {
317+
if (
318+
appMounted() ||
319+
recoveryInFlight ||
320+
recoveryAttempt >= maxRecoveryAttempts ||
321+
typeof window.fetch !== "function"
322+
)
323+
return;
324+
var documentUrl = new URL(window.location.href);
325+
if (recoveryNavigation) {
326+
setSummary(
327+
"A fresh page still could not start the Control UI. Try again, then check the troubleshooting guide if the problem persists.",
328+
);
329+
return;
330+
}
331+
recoveryInFlight = true;
332+
recoveryAttempt += 1;
333+
documentUrl.searchParams.set("openclaw_mount_recovery", String(Date.now()));
334+
window
335+
.fetch(documentUrl.href, { cache: "no-store", credentials: "same-origin" })
336+
.then(function (response) {
337+
if (!response.ok) throw new Error("gateway unavailable");
338+
window.location.replace(documentUrl.href);
339+
})
340+
.catch(function () {
341+
finishRecoveryAttempt();
342+
});
343+
}
344+
276345
function showFallback() {
277346
if (appMounted()) return;
347+
retryCurrentDocument();
278348
fallback.hidden = false;
279349
document.body.classList.add("openclaw-mount-fallback-active");
280350
if (panel && typeof panel.focus === "function") {
@@ -297,6 +367,7 @@ <h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
297367
window.customElements.whenDefined(tagName).then(
298368
function () {
299369
window.clearTimeout(timer);
370+
window.clearTimeout(recoveryTimer);
300371
hideFallback();
301372
},
302373
function () {},
@@ -311,6 +382,8 @@ <h1 id="openclaw-mount-fallback-title">Control UI did not start</h1>
311382
if (wait) {
312383
wait.addEventListener("click", function () {
313384
hideFallback();
385+
recoveryAttempt = 0;
386+
retryCurrentDocument();
314387
armFallbackTimer();
315388
});
316389
}

ui/src/app/mount-fallback.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Control UI tests cover mount fallback behavior.
22
import { readFile } from "node:fs/promises";
33
import path from "node:path";
4-
import { afterEach, describe, expect, it } from "vitest";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
55

66
const indexHtmlPath = path.resolve(
77
process.cwd(),
@@ -115,4 +115,38 @@ describe("Control UI mount fallback", () => {
115115
expect(fallback.hidden).toBe(true);
116116
expect([...frameWindow.document.body.classList]).toEqual([]);
117117
});
118+
119+
it("probes a cache-busted current document when the original bundle did not start", async () => {
120+
const frameWindow = createIsolatedWindow();
121+
const html = await readIndexHtmlWithDelay(1);
122+
const fetch = vi.fn().mockResolvedValue({ ok: false });
123+
Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
124+
installFallbackShell(frameWindow, html);
125+
126+
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
127+
128+
expect(fetch).toHaveBeenNthCalledWith(1, expect.stringContaining("openclaw_mount_recovery="), {
129+
cache: "no-store",
130+
credentials: "same-origin",
131+
});
132+
});
133+
134+
it("bounds automatic recovery attempts while the gateway is unavailable", async () => {
135+
const frameWindow = createIsolatedWindow();
136+
const fetch = vi.fn().mockRejectedValue(new Error("gateway unavailable"));
137+
Object.defineProperty(frameWindow, "fetch", { configurable: true, value: fetch });
138+
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
139+
140+
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(6));
141+
await waitForWindowTimeout(frameWindow, 10);
142+
143+
expect(fetch).toHaveBeenCalledTimes(6);
144+
expect(
145+
requireElementById(
146+
frameWindow,
147+
"openclaw-mount-fallback-summary",
148+
frameWindow.HTMLParagraphElement,
149+
).textContent,
150+
).toContain("gateway is still unavailable");
151+
});
118152
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import path from "node:path";
2+
import { chromium, type Browser } from "playwright";
3+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
4+
import {
5+
canRunPlaywrightChromium,
6+
installMockGateway,
7+
resolvePlaywrightChromiumExecutablePath,
8+
startControlUiE2eServer,
9+
type ControlUiE2eServer,
10+
} from "../test-helpers/control-ui-e2e.ts";
11+
12+
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
13+
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
14+
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
15+
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
16+
17+
let browser: Browser;
18+
let server: ControlUiE2eServer;
19+
20+
describeControlUiE2e("Control UI mount recovery E2E", () => {
21+
beforeAll(async () => {
22+
if (!chromiumAvailable) {
23+
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
24+
}
25+
server = await startControlUiE2eServer();
26+
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
27+
});
28+
29+
afterAll(async () => {
30+
await browser?.close();
31+
await server?.close();
32+
});
33+
34+
it("reloads a fresh document after the initial app module is unavailable", async () => {
35+
const artifactDir = path.resolve(".artifacts/control-ui-e2e/mount-recovery");
36+
const context = await browser.newContext({
37+
locale: "en-US",
38+
recordVideo: { dir: artifactDir, size: { height: 720, width: 1280 } },
39+
serviceWorkers: "block",
40+
viewport: { height: 720, width: 1280 },
41+
});
42+
const page = await context.newPage();
43+
const baseUrl = new URL(server.baseUrl);
44+
let documentRequests = 0;
45+
let failedModuleRequests = 0;
46+
await page.route(`${baseUrl.origin}/**`, async (route) => {
47+
const request = route.request();
48+
const url = new URL(request.url());
49+
if (request.resourceType() === "document") {
50+
documentRequests += 1;
51+
const response = await route.fetch();
52+
const body = (await response.text()).replace(
53+
'data-openclaw-mount-timeout-ms="12000"',
54+
'data-openclaw-mount-timeout-ms="50"',
55+
);
56+
await route.fulfill({ response, body });
57+
return;
58+
}
59+
if (url.pathname === "/src/main.ts" && failedModuleRequests === 0) {
60+
failedModuleRequests += 1;
61+
await route.fulfill({ body: "gateway restarting", status: 503 });
62+
return;
63+
}
64+
await route.continue();
65+
});
66+
await installMockGateway(page);
67+
68+
try {
69+
expect(
70+
(await page.goto(`${server.baseUrl}chat`, { waitUntil: "domcontentloaded" }))?.status(),
71+
).toBe(200);
72+
await page.locator("openclaw-app-shell").waitFor();
73+
await page.locator(".agent-chat__welcome").waitFor();
74+
75+
expect(documentRequests).toBe(2);
76+
expect(failedModuleRequests).toBe(1);
77+
await expect.poll(() => page.url()).not.toContain("openclaw_mount_recovery");
78+
await page.screenshot({ path: path.join(artifactDir, "recovered-control-ui.png") });
79+
} finally {
80+
await context.close();
81+
}
82+
});
83+
});

0 commit comments

Comments
 (0)