fix(ui): show recovery when Control UI does not render (#124861)

* fix(ui): retire bootstrap fallback after render

* test(ui): avoid returning from Promise executor
This commit is contained in:
Peter Steinberger 2026-08-16 15:46:15 -07:00 committed by GitHub
parent 358c06ec95
commit eadde4a9d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 235 additions and 36 deletions

View file

@ -754,7 +754,7 @@ Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
## Blank Control UI page
If the browser loads a blank dashboard and DevTools shows no useful error, an extension or early content script may have prevented the JavaScript module app from evaluating. The static page includes a plain HTML recovery panel that appears when `<openclaw-app>` is not registered after startup.
If the browser loads a blank dashboard and DevTools shows no useful error, an extension or early content script may have prevented the JavaScript module app from evaluating. The static page includes a plain HTML recovery panel that appears when `<openclaw-app>` does not complete its first render after startup.
Use the panel's **Try again** action after changing the browser environment, or reload manually after these checks:

View file

@ -313,8 +313,7 @@
</section>
<script>
(function () {
var tagName = "openclaw-app";
var app = document.querySelector(tagName);
var app = document.querySelector("openclaw-app");
var fallback = document.getElementById("openclaw-mount-fallback");
if (!app || !fallback) return;
@ -330,6 +329,7 @@
var recoveryAttempt = 0;
var recoveryInFlight = false;
var recoveryNavigation = false;
var appStarted = false;
try {
var initialUrl = new URL(window.location.href);
@ -340,19 +340,6 @@
}
} catch (e) {}
function appMounted() {
try {
return Boolean(
app.isConnected &&
window.customElements &&
typeof window.customElements.get === "function" &&
window.customElements.get(tagName),
);
} catch (e) {
return false;
}
}
function hideFallback() {
fallback.hidden = true;
document.body.classList.remove("openclaw-mount-fallback-active");
@ -364,14 +351,14 @@
function scheduleRecovery() {
window.clearTimeout(recoveryTimer);
if (appMounted() || recoveryAttempt >= maxRecoveryAttempts) return;
if (appStarted || 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 (appStarted) return;
if (recoveryAttempt >= maxRecoveryAttempts) {
setSummary(
"The gateway is still unavailable. Try again, then check the troubleshooting guide if the problem persists.",
@ -386,7 +373,7 @@
function retryCurrentDocument() {
if (
appMounted() ||
appStarted ||
recoveryInFlight ||
recoveryAttempt >= maxRecoveryAttempts ||
typeof window.fetch !== "function"
@ -414,7 +401,7 @@
})
.then(function (response) {
if (!response.ok) throw new Error("gateway unavailable");
window.location.replace(documentUrl.href);
if (!appStarted) window.location.replace(documentUrl.href);
})
.catch(function () {
finishRecoveryAttempt();
@ -425,7 +412,7 @@
}
function showFallback() {
if (appMounted()) return;
if (appStarted) return;
retryCurrentDocument();
fallback.hidden = false;
document.body.classList.add("openclaw-mount-fallback-active");
@ -445,16 +432,16 @@
armFallbackTimer();
if (window.customElements && typeof window.customElements.whenDefined === "function") {
window.customElements.whenDefined(tagName).then(
function () {
window.clearTimeout(timer);
window.clearTimeout(recoveryTimer);
hideFallback();
},
function () {},
);
}
window.addEventListener(
"openclaw-control-ui-rendered",
function () {
appStarted = true;
window.clearTimeout(timer);
window.clearTimeout(recoveryTimer);
hideFallback();
},
{ once: true },
);
if (retry) {
retry.addEventListener("click", function () {

View file

@ -151,6 +151,12 @@ export class OpenClawApp extends OpenClawLightDomElement {
super.disconnectedCallback();
}
protected override firstUpdated(): void {
if (this.runtime) {
globalThis.dispatchEvent(new Event("openclaw-control-ui-rendered"));
}
}
private synchronizeGateway(gateway: ApplicationContext["gateway"]) {
const sourceChanged = gateway !== this.loginGatewaySource;
if (sourceChanged) {

View file

@ -114,9 +114,8 @@ describe("Control UI mount fallback", () => {
},
);
it("shows the static troubleshooting panel when the app element is never registered", async () => {
it("shows the static troubleshooting panel when the app never renders", async () => {
const frameWindow = createIsolatedWindow();
expect(frameWindow.customElements.get("openclaw-app")).toBeUndefined();
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
await waitForWindowTimeout(frameWindow, 10);
@ -147,20 +146,25 @@ describe("Control UI mount fallback", () => {
expect(fallback.hidden).toBe(false);
});
it("keeps the fallback hidden when the app element registers before the timeout", async () => {
it("keeps the fallback visible until the app completes its first render", async () => {
const frameWindow = createIsolatedWindow();
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(25));
installFallbackShell(frameWindow, await readIndexHtmlWithDelay(1));
if (!frameWindow.customElements.get("openclaw-app")) {
frameWindow.customElements.define("openclaw-app", class extends frameWindow.HTMLElement {});
}
await frameWindow.customElements.whenDefined("openclaw-app");
await waitForWindowTimeout(frameWindow, 35);
await waitForWindowTimeout(frameWindow, 10);
const fallback = requireElementById(
frameWindow,
"openclaw-mount-fallback",
frameWindow.HTMLElement,
);
expect(fallback.hidden).toBe(false);
expect([...frameWindow.document.body.classList]).toEqual(["openclaw-mount-fallback-active"]);
frameWindow.dispatchEvent(new frameWindow.Event("openclaw-control-ui-rendered"));
expect(fallback.hidden).toBe(true);
expect([...frameWindow.document.body.classList]).toEqual([]);
});

View file

@ -322,6 +322,45 @@ suite.define(() => {
}
});
it("retires the static startup fallback after rendering auth-required guidance", async () => {
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
await page.addInitScript(() => {
window.addEventListener("openclaw-control-ui-rendered", () => {
const key = "openclaw.control-ui-e2e.render-count";
const count = Number.parseInt(sessionStorage.getItem(key) ?? "0", 10);
sessionStorage.setItem(key, String(count + 1));
});
});
await page.clock.install();
const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
try {
await page.goto(suite.server.baseUrl);
await gateway.waitForRequest("connect");
await gateway.rejectDeferred("connect", {
code: "INVALID_REQUEST",
message: "token missing",
details: { code: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING },
});
const authRequired = page.locator('.login-gate__failure[data-kind="auth-required"]');
await authRequired.waitFor({ timeout: 10_000 });
await page.clock.runFor(12_001);
expect(await authRequired.isVisible()).toBe(true);
expect(await page.locator("#openclaw-mount-fallback").isHidden()).toBe(true);
expect((await page.locator("body").getAttribute("class")) ?? "").not.toContain(
"openclaw-mount-fallback-active",
);
expect(
await page.evaluate(() => sessionStorage.getItem("openclaw.control-ui-e2e.render-count")),
).toBe("1");
} finally {
await closeContext(context);
}
});
it("keeps mobile controls compact, touchable, and keyboard-friendly", async () => {
const context = await suite.browser.newContext({
hasTouch: true,

View file

@ -0,0 +1,163 @@
import { readFile } from "node:fs/promises";
import { createServer } from "node:http";
import path from "node:path";
import type { Page } from "playwright";
import { expect, it } from "vitest";
import { pauseVirtualClock, type ControlUiE2eServer } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const indexHtmlPath = path.resolve(
process.cwd(),
path.basename(process.cwd()) === "ui" ? "index.html" : "ui/index.html",
);
const renderEvent = "openclaw-control-ui-rendered";
const loadCountKey = "openclaw.control-ui-e2e.mount-fallback-loads";
const renderCountKey = "openclaw.control-ui-e2e.mount-fallback-renders";
let syntheticModuleRenders = false;
async function startRegisteredElementFixture(): Promise<ControlUiE2eServer> {
const indexHtml = await readFile(indexHtmlPath, "utf8");
const server = createServer((request, response) => {
const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
if (requestUrl.pathname === "/src/main.ts") {
response.setHeader("content-type", "text/javascript; charset=utf-8");
response.end(
syntheticModuleRenders
? `customElements.define("openclaw-app", class extends HTMLElement {
connectedCallback() {
this.textContent = "Application rendered";
window.dispatchEvent(new Event(${JSON.stringify(renderEvent)}));
}
});`
: 'customElements.define("openclaw-app", class extends HTMLElement {});',
);
return;
}
if (requestUrl.pathname === "/") {
response.setHeader("content-type", "text/html; charset=utf-8");
response.end(indexHtml);
return;
}
response.statusCode = 404;
response.end();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Registered-element fixture did not acquire a loopback port");
}
return {
baseUrl: `http://127.0.0.1:${address.port}/`,
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
}
async function addLifecycleCounters(page: Page): Promise<void> {
await page.addInitScript(
({ loadKey, renderedEvent, renderKey }) => {
const loadCount = Number.parseInt(sessionStorage.getItem(loadKey) ?? "0", 10);
sessionStorage.setItem(loadKey, String(loadCount + 1));
window.addEventListener(renderedEvent, () => {
const renderCount = Number.parseInt(sessionStorage.getItem(renderKey) ?? "0", 10);
sessionStorage.setItem(renderKey, String(renderCount + 1));
});
},
{ loadKey: loadCountKey, renderedEvent: renderEvent, renderKey: renderCountKey },
);
}
const registeredElementSuite = createControlUiE2eSuite({
name: "Control UI static mount fallback E2E",
startServer: startRegisteredElementFixture,
startServerBeforeBrowser: true,
});
registeredElementSuite.define(() => {
it("shows the fallback when registration never produces an application render", async () => {
syntheticModuleRenders = false;
await registeredElementSuite.withPage(
{ serviceWorkers: "block", viewport: { height: 900, width: 1280 } },
async ({ page }) => {
await addLifecycleCounters(page);
await page.clock.install();
await pauseVirtualClock(page);
await page.goto(registeredElementSuite.server.baseUrl, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => customElements.get("openclaw-app") !== undefined);
expect(await page.locator("openclaw-app").textContent()).toBe("");
expect(
await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey),
).toBeNull();
await page.clock.runFor(12_001);
await page.waitForFunction((key) => sessionStorage.getItem(key) === "2", loadCountKey);
await page.clock.runFor(12_001);
await page.getByRole("heading", { name: "Control UI did not start" }).waitFor();
expect(await page.getByRole("button", { name: "Try again" }).isVisible()).toBe(true);
expect(await page.getByRole("button", { name: "Keep waiting" }).isVisible()).toBe(true);
expect(
await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey),
).toBeNull();
syntheticModuleRenders = true;
await Promise.all([
page.waitForNavigation({ waitUntil: "domcontentloaded" }),
page.getByRole("button", { name: "Try again" }).click(),
]);
await page.getByText("Application rendered", { exact: true }).waitFor();
await page.clock.runFor(12_001);
expect(await page.locator("#openclaw-mount-fallback").isHidden()).toBe(true);
expect(await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey)).toBe("1");
},
);
});
});
const runtimeFailureSuite = createControlUiE2eSuite({
name: "Control UI failed runtime mount E2E",
startServerBeforeBrowser: true,
});
runtimeFailureSuite.define(() => {
it("does not complete startup when application runtime creation throws", async () => {
await runtimeFailureSuite.withPage(
{ serviceWorkers: "block", viewport: { height: 900, width: 1280 } },
async ({ page }) => {
await addLifecycleCounters(page);
await page.addInitScript(() => {
const browserGetComputedStyle = globalThis.getComputedStyle.bind(globalThis);
Object.defineProperty(globalThis, "getComputedStyle", {
configurable: true,
value: (element: Element, pseudoElement?: string | null) => {
if (customElements.get("openclaw-app")) {
throw new Error("forced application runtime creation failure");
}
return browserGetComputedStyle(element, pseudoElement);
},
});
});
await page.clock.install();
await pauseVirtualClock(page);
await page.goto(runtimeFailureSuite.server.baseUrl, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => customElements.get("openclaw-app") !== undefined);
await page.clock.runFor(12_001);
await page.waitForFunction((key) => sessionStorage.getItem(key) === "2", loadCountKey);
await page.clock.runFor(12_001);
await page.getByRole("heading", { name: "Control UI did not start" }).waitFor();
expect(
await page.evaluate((key) => sessionStorage.getItem(key), renderCountKey),
).toBeNull();
},
);
});
});