mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
perf(ui): consolidate Control UI boot chunk graph for HTTP/1.1 gateways (#128514)
* perf(ui): consolidate Control UI boot chunk graph for HTTP/1.1 gateways The Control UI boot flow (app shell + sidebar + chat route) lazily loaded ~124 automatic chunks in one burst after the gateway handshake, which the gateway's HTTP/1.1 transport serializes into ~24 six-connection round-trips on high-latency links (Tailscale, remote gateways). Add a measured boot-module manifest (ui/config/control-ui-boot-modules.json, regenerated via pnpm ui:boot-manifest:gen) and a control-ui-boot codeSplitting group that merges exactly that module set into a handful of chunks with recursive dependency inclusion. Lazy islands (locales, ghostty-web, novnc, non-default routes) keep their own chunks; stale manifest entries degrade gracefully back to automatic chunking. Measured on the built dist with the mocked gateway (chat route, 3 runs): unique boot JS requests 140 -> 45, raw boot JS 3751 -> 3717 KiB, chat composer interactive at simulated 50 ms RTT ~1600 ms -> ~575 ms. Largest-CSS budget rises 45 -> 47 KiB for the merged boot CSS; startup JS gzip baseline ratchets down (345049 -> 339214 B) as consolidation shrinks the startup graph. * chore(ui): refresh boot module manifest after rebase onto current main * fix(ui): stop the pending lazy shell action replay loop starving boot When a pending lazy shell action (command palette open, panel toggle) replayed while the shell was still splash-gated, the dispatched event had no rendered element to consume it and re-entered requestLazyElement in a microtask cycle: request -> load -> replay -> dispatch -> request. The cycle starved tasks (Gateway WebSocket messages included), so the boot never finished and the recovery e2e froze on the splash screen. Gate replay on the element actually being rendered: the controller skips the action after load until the host's render root contains the tag, and restorePendingLazyAction skips dispatch while a defined element is still render-gated. The host retries after every completed update, so the replay fires on the update that first renders the element. Regression test fails on the pre-fix controller. * fix(ui): re-anchor the scope-upgrade details popover before opening wa-popover resolves its `for` target once per property change and never re-resolves a missing or replaced anchor. The trigger with the shared id can render after the popover's first update (the header trigger ships with the lazy chat chunk), leaving the opened popover permanently invisible: active popup with a native [popover] part stuck at UA display:none because showPopover() never ran without an anchor. Re-arm the watcher when opening while the anchor is missing or disconnected. * test(ui): compare settled layouts in device-scope stability assertions The 0.5px no-move assertions sampled geometry that later reflowed when the details surface's first render fetched glyph subsets, reporting sub-pixel drift the open never caused. Burn in the one-time open per context and sample the baseline adjacent to the click. * fix(ui): map the keyboard shortcuts dialog in lazy replay gating Current main added the keyboard-shortcuts lazy shell event; the replay gate's exhaustive event-to-element record needs its entry. * chore(ui): refresh startup budget baseline after rebase onto current main
This commit is contained in:
committed by
GitHub
parent
d9165f9813
commit
8fe2c1b83c
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"startupJsGzipBytes": 345049,
|
||||
"reason": "streamed-markdown highlighting changes (#127749, #127754) consumed the ratchet",
|
||||
"updatedAt": "2026-08-22"
|
||||
"startupJsGzipBytes": 340388,
|
||||
"reason": "rebase onto current main; startup growth landed on main since the boot-group ratchet",
|
||||
"updatedAt": "2026-08-24"
|
||||
}
|
||||
|
||||
@@ -2004,6 +2004,7 @@
|
||||
"tui:pty:test:watch:all": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode all",
|
||||
"tui:pty:test:watch:fake": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode fake",
|
||||
"tui:pty:test:watch:local": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode local",
|
||||
"ui:boot-manifest:gen": "node --import tsx scripts/control-ui-boot-manifest.mts",
|
||||
"ui:build": "node scripts/ui.js build",
|
||||
"ui:dev": "node scripts/ui.js dev",
|
||||
"ui:i18n:baseline": "node --import tsx scripts/control-ui-i18n-verify.ts baseline",
|
||||
|
||||
@@ -33,7 +33,11 @@ const controlUiPerformanceBudgets = {
|
||||
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
|
||||
startupCssGzipBytes: 45 * KIB,
|
||||
largestJsGzipBytes: 215 * KIB,
|
||||
largestCssGzipBytes: 45 * KIB,
|
||||
// Startup CSS stays at 45 KiB; the boot-group consolidation (2026-08,
|
||||
// control-ui-boot chunking) merges boot-path component CSS into one file
|
||||
// that lands just above it, trading ~1 KiB of ceiling for ~95 fewer boot
|
||||
// requests on HTTP/1.1 gateways.
|
||||
largestCssGzipBytes: 47 * KIB,
|
||||
} satisfies Record<string, number>;
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets);
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env -S node --import tsx
|
||||
// Regenerates ui/config/control-ui-boot-modules.json: the measured module set
|
||||
// the default Control UI boot flow loads lazily. Boots the built dist bundle
|
||||
// against the mocked Gateway, records every JS chunk fetched through chat
|
||||
// readiness, and unions their sourcemap sources into canonical manifest keys.
|
||||
// Requires a current `pnpm ui:build` output in dist/control-ui.
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { chromium } from "playwright";
|
||||
import { controlUiBootManifestKey } from "../ui/config/control-ui-chunking.ts";
|
||||
import { installMockGateway } from "../ui/src/test-helpers/control-ui-e2e.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const distDir = path.join(repoRoot, "dist", "control-ui");
|
||||
const manifestPath = path.join(repoRoot, "ui", "config", "control-ui-boot-modules.json");
|
||||
const SETTLE_MS = 3_000;
|
||||
const READY_TIMEOUT_MS = 60_000;
|
||||
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".map": "application/json",
|
||||
".webmanifest": "application/manifest+json",
|
||||
};
|
||||
|
||||
function serveDist(): Promise<{ baseUrl: string; close: () => void }> {
|
||||
const server = http.createServer((req, res) => {
|
||||
const urlPath = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (urlPath === "/control-ui-config.json") {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ basePath: "/", assistantName: "", assistantAvatar: "" }));
|
||||
return;
|
||||
}
|
||||
let filePath = path.join(distDir, urlPath === "/" ? "index.html" : urlPath.slice(1));
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(distDir, "index.html");
|
||||
}
|
||||
res.setHeader("Content-Type", mime[path.extname(filePath)] ?? "application/octet-stream");
|
||||
res.end(fs.readFileSync(filePath));
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address !== "object") {
|
||||
throw new Error("Control UI boot manifest server has no port");
|
||||
}
|
||||
resolve({
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readDistBuildId(): string {
|
||||
const swSource = fs.readFileSync(path.join(distDir, "sw.js"), "utf8");
|
||||
const buildId = /EMBEDDED_CACHE_VERSION = "([^"]+)"/.exec(swSource)?.[1];
|
||||
if (!buildId) {
|
||||
throw new Error("Control UI boot manifest cannot read the dist build id from sw.js");
|
||||
}
|
||||
return buildId;
|
||||
}
|
||||
|
||||
async function collectBootChunkPaths(baseUrl: string): Promise<Set<string>> {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const chunkPaths = new Set<string>();
|
||||
page.on("request", (request) => {
|
||||
const { pathname } = new URL(request.url());
|
||||
if (pathname.startsWith("/assets/") && pathname.endsWith(".js")) {
|
||||
chunkPaths.add(pathname);
|
||||
}
|
||||
});
|
||||
await installMockGateway(page, { serverBuildId: readDistBuildId() });
|
||||
await page.goto(`${baseUrl}/chat`, { waitUntil: "commit" });
|
||||
// Chat readiness proves the boot flow completed instead of stalling on an
|
||||
// error surface; a manifest captured from a broken boot would be garbage.
|
||||
await page
|
||||
.locator(".agent-chat__composer-combobox textarea")
|
||||
.waitFor({ timeout: READY_TIMEOUT_MS });
|
||||
await page.waitForTimeout(SETTLE_MS);
|
||||
return chunkPaths;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
function manifestKeysForChunks(chunkPaths: Iterable<string>): string[] {
|
||||
const keys = new Set<string>();
|
||||
for (const chunkPath of chunkPaths) {
|
||||
const mapPath = path.join(distDir, `${chunkPath}.map`);
|
||||
if (!fs.existsSync(mapPath)) {
|
||||
// Facade chunks for dynamic entries can omit maps; their modules are
|
||||
// covered by the chunks that carry the actual code.
|
||||
continue;
|
||||
}
|
||||
const map = JSON.parse(fs.readFileSync(mapPath, "utf8")) as { sources?: string[] };
|
||||
for (const source of map.sources ?? []) {
|
||||
keys.add(controlUiBootManifestKey(path.resolve(path.join(distDir, "assets"), source)));
|
||||
}
|
||||
}
|
||||
return [...keys].toSorted();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (!fs.existsSync(path.join(distDir, "index.html"))) {
|
||||
throw new Error(`No Control UI build at ${distDir}; run \`pnpm ui:build\` first`);
|
||||
}
|
||||
const server = await serveDist();
|
||||
try {
|
||||
const chunkPaths = await collectBootChunkPaths(server.baseUrl);
|
||||
const keys = manifestKeysForChunks(chunkPaths);
|
||||
if (keys.length < 100) {
|
||||
throw new Error(`Boot capture looks truncated: only ${keys.length} modules recorded`);
|
||||
}
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(keys, null, 1)}\n`);
|
||||
console.log(
|
||||
`control-ui-boot-manifest: ${chunkPaths.size} boot chunks -> ${keys.length} modules -> ${path.relative(repoRoot, manifestPath)}`,
|
||||
);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
console.error("[control-ui-boot-manifest] FAILED (exit 1)");
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -42,6 +42,10 @@ This directory owns Control UI-specific guidance that should not live in the rep
|
||||
- Method-advertisement checks (`isGatewayMethodAdvertised`) remain only as feature gates for config/plugin-dependent surfaces, never as version compat.
|
||||
- The handshake rejects gateway-served same-origin skew. The admission-exempt paths (`pnpm ui:dev`, custom `gateway.controlUi.root`, cross-origin/connection-settings dialing) are unsupported for version mismatch without enforcement: they carry no compat code and fail visibly at the first missing method, by design. Tightening admission to reject them at connect is a server-side product change owned separately.
|
||||
|
||||
## Build Chunking
|
||||
|
||||
- `ui/config/control-ui-boot-modules.json` is a generated manifest of the modules the default boot flow loads lazily; the `control-ui-boot` group in `ui/config/control-ui-chunking.ts` merges them into a few chunks so boot avoids ~140 HTTP/1.1 requests. Regenerate with `pnpm ui:boot-manifest:gen` after `pnpm ui:build` when boot-path surfaces change materially; stale entries degrade to extra chunks, never breakage. Do not hand-edit the manifest.
|
||||
|
||||
## Live Verification
|
||||
|
||||
- The Gateway serves the prebuilt bundle from `dist/control-ui`; editing `ui/src` changes nothing live until `pnpm ui:build`. Confirm the served `/assets/index-*.js` hash changed before trusting a live result.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,36 @@
|
||||
// Control UI config module wires control ui chunking behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const configDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(configDir, "../..");
|
||||
// Measured module set the default boot flow (app shell + sidebar + chat route)
|
||||
// loads through dynamic imports. Regenerate with `pnpm ui:boot-manifest:gen`
|
||||
// after a build; stale entries degrade gracefully back to automatic chunking.
|
||||
const controlUiBootModules: ReadonlySet<string> = new Set(
|
||||
JSON.parse(
|
||||
fs.readFileSync(path.join(configDir, "control-ui-boot-modules.json"), "utf8"),
|
||||
) as string[],
|
||||
);
|
||||
|
||||
function normalizeModuleId(id: string): string {
|
||||
return id.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
export function controlUiBootManifestKey(id: string): string {
|
||||
// Canonical manifest key: vendor modules key from their innermost
|
||||
// node_modules entry so pnpm virtual-store paths match; first-party modules
|
||||
// key repo-relative.
|
||||
const stripped = id.replace(/[?#].*$/u, "");
|
||||
const normalized = normalizeModuleId(stripped);
|
||||
const vendorIndex = normalized.lastIndexOf("/node_modules/");
|
||||
if (vendorIndex !== -1) {
|
||||
return `node_modules/${normalized.slice(vendorIndex + "/node_modules/".length)}`;
|
||||
}
|
||||
return normalizeModuleId(path.relative(repoRoot, stripped));
|
||||
}
|
||||
|
||||
function moduleIdIncludesPackage(id: string, packageName: string): boolean {
|
||||
const normalized = normalizeModuleId(id);
|
||||
return (
|
||||
@@ -74,5 +102,23 @@ export const controlUiCodeSplitting = {
|
||||
// split it into two extra requests and added roughly 1 KiB of gzip.
|
||||
maxSize: 640 * 1024,
|
||||
},
|
||||
{
|
||||
// Boot-path consolidation: the lazily-loaded modules the default boot
|
||||
// flow always fetches (~124 automatic chunks without this group) merge
|
||||
// into a handful of chunks so the gateway's HTTP/1.1 6-connection
|
||||
// transport pays ~7 instead of ~24 serialized round-trips on high-latency
|
||||
// links. Byte cost is ~zero: every captured module is fetched during boot
|
||||
// either way. Recursive dependency inclusion is required for correctness
|
||||
// here — merging without it emitted chunks whose execution order broke at
|
||||
// startup ("TypeError: X is not a function" during application start).
|
||||
name: "control-ui-boot",
|
||||
test: (id: string) => controlUiBootModules.has(controlUiBootManifestKey(id)),
|
||||
priority: 8,
|
||||
includeDependenciesRecursively: true,
|
||||
// Larger ceiling than the startup groups: this sizes pre-minification
|
||||
// module bytes, and ~1.5 MiB keeps the largest emitted chunk near
|
||||
// ~190 KiB gzip, inside the 215 KiB largest-JS budget.
|
||||
maxSize: 1536 * 1024,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -50,6 +50,13 @@ function configureTerminalShell(terminalElement: TestOptionalCustomElement): She
|
||||
configurable: true,
|
||||
get: () => Promise.resolve(true),
|
||||
});
|
||||
// The production shell keeps panel tags mounted before definition; replay is
|
||||
// gated on the rendered element, so the harness mounts the tag the same way.
|
||||
Object.defineProperty(shell, "renderRoot", {
|
||||
configurable: true,
|
||||
get: () => shell,
|
||||
});
|
||||
(shell as unknown as HTMLElement).appendChild(document.createElement(terminalElement.tagName));
|
||||
return shell;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,3 +44,15 @@ export function createLazyElementSpec(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay is gated on the rendered element; harnesses that model "rendered as
|
||||
* soon as the module defines the tag" install this stub on the fake shell.
|
||||
*/
|
||||
export function stubRenderedWhenDefined(shell: object): void {
|
||||
Object.defineProperty(shell, "queryRenderedElement", {
|
||||
configurable: true,
|
||||
value: (tagName: string) =>
|
||||
customElements.get(tagName) ? document.createElement(tagName) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
resetAppHostTestGlobals,
|
||||
type ShellKeyboardState,
|
||||
type TestOptionalCustomElement,
|
||||
stubRenderedWhenDefined,
|
||||
} from "./app-host.test-support.ts";
|
||||
import { ShellGatewayOwner, type ShellGatewayHost } from "./app-shell-gateway.ts";
|
||||
import type {
|
||||
@@ -890,16 +891,9 @@ describe("OpenClaw shell keyboard shortcuts", () => {
|
||||
|
||||
it("normalizes an unloaded palette toggle shortcut to open", async () => {
|
||||
const element = createLazyElementSpec("command palette");
|
||||
const shell = document.createElement("openclaw-app-shell") as unknown as ShellLazySurfaceState;
|
||||
shell.commandPaletteElement = element;
|
||||
const openPalette = vi.fn();
|
||||
Object.defineProperty(shell, "updateComplete", { get: () => Promise.resolve(true) });
|
||||
Object.defineProperty(shell, "commandPalette", {
|
||||
get: () =>
|
||||
customElements.get(element.tagName)
|
||||
? { isOpen: false, openPalette, togglePalette: vi.fn() }
|
||||
: undefined,
|
||||
});
|
||||
const shell = configureLazyPaletteShell(element, openPalette);
|
||||
stubRenderedWhenDefined(shell);
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key: "л",
|
||||
code: "KeyK",
|
||||
|
||||
@@ -162,6 +162,10 @@ class OpenClawShell
|
||||
() =>
|
||||
hasStoredLazyShellAction() ? retryStaleChunkReloadWhenReachable() : Promise.resolve(false),
|
||||
);
|
||||
// Gates lazy-action replay on the element being rendered; while the shell is
|
||||
// still splash-gated, replaying would loop through the open handlers forever.
|
||||
readonly queryRenderedElement = (tagName: string): Element | null =>
|
||||
this.renderRoot?.querySelector(tagName) ?? null;
|
||||
@query("openclaw-command-palette") commandPalette: CommandPaletteElement | undefined;
|
||||
@query("openclaw-exec-approval")
|
||||
approvalOverlay: (HTMLElement & { show(): void; dialogOpen?: boolean }) | undefined;
|
||||
@@ -655,6 +659,9 @@ class OpenClawShell
|
||||
|
||||
override updated() {
|
||||
this.syncDocumentTitle();
|
||||
// Render-gated pending lazy actions replay on the update that first
|
||||
// renders their element, independent of further context updates.
|
||||
this.restorePendingLazyAction();
|
||||
if (
|
||||
!customElements.get("openclaw-sidebar-update-card") &&
|
||||
this.querySelector("openclaw-sidebar-update-card")
|
||||
|
||||
@@ -629,14 +629,36 @@ export class ShellChromeOwner {
|
||||
}
|
||||
};
|
||||
|
||||
private lazyElementForShellEvent(eventType: LazyShellEvent["eventType"]): OptionalCustomElement {
|
||||
const host = this.host;
|
||||
const elements: Record<LazyShellEvent["eventType"], OptionalCustomElement> = {
|
||||
[COMMAND_PALETTE_OPEN_EVENT]: host.commandPaletteElement,
|
||||
[DEBUG_OVERLAY_REQUEST_EVENT]: DEBUG_OVERLAY_ELEMENT,
|
||||
[KEYBOARD_SHORTCUTS_REQUEST_EVENT]: KEYBOARD_SHORTCUTS_ELEMENT,
|
||||
[TERMINAL_PANEL_TOGGLE_EVENT]: host.terminalPanelElement,
|
||||
[BROWSER_PANEL_TOGGLE_EVENT]: host.browserPanelElement,
|
||||
[DESKTOP_PANEL_TOGGLE_EVENT]: host.desktopPanelElement,
|
||||
[CUSTODIAN_PANEL_TOGGLE_EVENT]: host.custodianPanelElement,
|
||||
[SHELL_APPROVALS_OPEN_EVENT]: host.execApprovalElement,
|
||||
};
|
||||
return elements[eventType];
|
||||
}
|
||||
|
||||
restorePendingLazyAction(): void {
|
||||
const event = this.pendingLazyAction;
|
||||
if (
|
||||
event &&
|
||||
!this.host.lazyCustomElements.visibleState &&
|
||||
this.dispatchLazyShellEvent(event) &&
|
||||
!this.host.lazyCustomElements.visibleState
|
||||
) {
|
||||
if (!event || this.host.lazyCustomElements.visibleState) {
|
||||
return;
|
||||
}
|
||||
const element = this.lazyElementForShellEvent(event.eventType);
|
||||
if (isOptionalElementDefined(element) && !this.host.querySelector(element.tagName)) {
|
||||
// Loaded but render-gated (e.g. the shell is still booting): nothing can
|
||||
// consume the dispatch yet, and re-dispatching re-arms a request/update
|
||||
// cycle whose microtasks starve the boot (Gateway socket included).
|
||||
// The host retries after every completed update, so the replay fires on
|
||||
// the update that first renders the element.
|
||||
return;
|
||||
}
|
||||
if (this.dispatchLazyShellEvent(event) && !this.host.lazyCustomElements.visibleState) {
|
||||
this.clearPendingLazyAction(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
controlUiBootManifestKey,
|
||||
controlUiCodeSplitting,
|
||||
controlUiStableChunkName,
|
||||
} from "../../config/control-ui-chunking.ts";
|
||||
@@ -49,6 +50,43 @@ describe("Control UI build chunking", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("consolidates the measured boot module set with recursive dependencies", () => {
|
||||
// Recursive inclusion is a correctness requirement for this group: merging
|
||||
// the lazy boot graph without it emitted chunks whose execution order broke
|
||||
// at application start.
|
||||
expect(controlUiCodeSplitting.groups[2]).toMatchObject({
|
||||
name: "control-ui-boot",
|
||||
includeDependenciesRecursively: true,
|
||||
});
|
||||
const bootGroup = controlUiCodeSplitting.groups[2] as {
|
||||
test: (id: string) => boolean;
|
||||
};
|
||||
const repoRoot = new URL("../../..", import.meta.url).pathname.replace(/\/$/, "");
|
||||
// Representative always-loaded boot surface and a lazy island that must
|
||||
// keep its own chunk (terminal runtime is not part of the default boot).
|
||||
expect(bootGroup.test(`${repoRoot}/ui/src/components/app-sidebar.ts`)).toBe(true);
|
||||
expect(bootGroup.test(`${repoRoot}/node_modules/ghostty-web/dist/index.js`)).toBe(false);
|
||||
});
|
||||
|
||||
it("derives stable manifest keys across pnpm layouts and platforms", () => {
|
||||
expect(
|
||||
controlUiBootManifestKey(
|
||||
"/repo/node_modules/.pnpm/nanoid@5.0.0/node_modules/nanoid/index.browser.js",
|
||||
),
|
||||
).toBe("node_modules/nanoid/index.browser.js");
|
||||
expect(
|
||||
controlUiBootManifestKey(
|
||||
"/repo/node_modules/@awesome.me/webawesome/node_modules/nanoid/index.browser.js",
|
||||
),
|
||||
).toBe("node_modules/nanoid/index.browser.js");
|
||||
expect(controlUiBootManifestKey("/repo/ui/src/main.ts?html-proxy&index=0.js")).not.toContain(
|
||||
"?",
|
||||
);
|
||||
expect(controlUiBootManifestKey(String.raw`C:\repo\node_modules\nanoid\index.browser.js`)).toBe(
|
||||
"node_modules/nanoid/index.browser.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes Windows module paths before package matching", () => {
|
||||
expect(
|
||||
controlUiStableChunkName(String.raw`C:\repo\ui\node_modules\highlight.js\lib\core.js`),
|
||||
|
||||
@@ -158,9 +158,34 @@ class ScopeUpgradeSurface extends OpenClawLightDomContentsElement {
|
||||
this.reopenAfterClose = true;
|
||||
return;
|
||||
}
|
||||
void this.openDetails();
|
||||
};
|
||||
|
||||
private async openDetails(): Promise<void> {
|
||||
if (!this.props?.mobile) {
|
||||
await this.ensureDetailsPopoverAnchored();
|
||||
}
|
||||
this.detailsOpen = true;
|
||||
this.requestUpdate();
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureDetailsPopoverAnchored(): Promise<void> {
|
||||
const popover = this.querySelector<
|
||||
HTMLElement & { anchor?: Element | null; for?: string; updateComplete?: Promise<unknown> }
|
||||
>("wa-popover.scope-upgrade-details-popover");
|
||||
if (!popover || popover.anchor?.isConnected) {
|
||||
return;
|
||||
}
|
||||
// wa-popover resolves `for` only when the property changes and never
|
||||
// re-resolves a missing or replaced anchor. The trigger with this id can
|
||||
// render after the popover's first update (the header trigger ships with
|
||||
// the lazy chat chunk), which would leave the opened popover permanently
|
||||
// invisible. Re-arm the watcher so it re-runs the id lookup now.
|
||||
popover.for = "";
|
||||
await popover.updateComplete;
|
||||
popover.for = SCOPE_UPGRADE_TRIGGER_ID;
|
||||
await popover.updateComplete;
|
||||
}
|
||||
|
||||
private readonly showDetailsFromTrigger = (event: Event) => {
|
||||
if (!this.props?.mobile) {
|
||||
|
||||
@@ -101,6 +101,38 @@ describe("optional custom element requests", () => {
|
||||
expect(requests.visibleState).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips the action replay while the host has not rendered the element", async () => {
|
||||
// Regression: replaying while the shell is still splash-gated re-dispatches
|
||||
// an event nothing handles, which re-enters the controller in a microtask
|
||||
// cycle that starves the boot (Gateway socket included).
|
||||
const requestUpdate = vi.fn();
|
||||
const rendered = new Map<string, Element>();
|
||||
const host = {
|
||||
requestUpdate,
|
||||
updateComplete: Promise.resolve(true),
|
||||
queryRenderedElement: (tagName: string) => rendered.get(tagName) ?? null,
|
||||
};
|
||||
const requests = new LazyCustomElementRequestController(host, undefined, async () => false);
|
||||
const action = vi.fn();
|
||||
const tagName = uniqueTag();
|
||||
const element = {
|
||||
tagName,
|
||||
label: "test panel",
|
||||
loadModule: async () => {
|
||||
customElements.define(tagName, class extends HTMLElement {});
|
||||
},
|
||||
};
|
||||
|
||||
requests.request(element, action);
|
||||
await waitForFast(() => expect(requests.visibleState).toBeUndefined());
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
|
||||
rendered.set(tagName, document.createElement(tagName));
|
||||
requests.request(element, action);
|
||||
await waitForFast(() => expect(action).toHaveBeenCalledOnce());
|
||||
expect(requests.visibleState).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resumes an active request after a foreground request replaces its visible slot", async () => {
|
||||
let rejectActive: ((error: Error) => void) | undefined;
|
||||
let resolveForeground: (() => void) | undefined;
|
||||
|
||||
@@ -42,6 +42,11 @@ export type OptionalCustomElement = {
|
||||
type UpdatingHost = {
|
||||
requestUpdate: () => unknown;
|
||||
readonly updateComplete?: Promise<unknown>;
|
||||
/**
|
||||
* Render-root lookup used to gate action replay on the element actually
|
||||
* being rendered. Hosts without it replay unconditionally.
|
||||
*/
|
||||
queryRenderedElement?: (tagName: string) => Element | null;
|
||||
};
|
||||
|
||||
type LazyCustomElementRequestState =
|
||||
@@ -182,7 +187,19 @@ export class LazyCustomElementRequestController {
|
||||
this.host.requestUpdate();
|
||||
await this.host.updateComplete;
|
||||
if (this.current === request) {
|
||||
request.action?.();
|
||||
// Replay only once the host has actually rendered the element.
|
||||
// During boot the shell can still be splash-gated after this update;
|
||||
// replaying then re-dispatches an event nothing handles, which
|
||||
// re-enters this controller in a microtask cycle that starves the
|
||||
// render (and the Gateway socket) forever. The skipped action stays
|
||||
// persisted as the pending lazy shell action and replays through
|
||||
// restorePendingLazyAction on a later context update.
|
||||
const replayable =
|
||||
!this.host.queryRenderedElement ||
|
||||
this.host.queryRenderedElement(request.element.tagName) !== null;
|
||||
if (replayable) {
|
||||
request.action?.();
|
||||
}
|
||||
if (this.current === request) {
|
||||
this.abandon();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resetAppHostTestGlobals,
|
||||
type ShellKeyboardState,
|
||||
type TestOptionalCustomElement,
|
||||
stubRenderedWhenDefined,
|
||||
} from "./app-host.test-support.ts";
|
||||
import "./app-host.ts";
|
||||
import { DEBUG_OVERLAY_ELEMENT, KEYBOARD_SHORTCUTS_ELEMENT } from "./lazy-custom-element.ts";
|
||||
@@ -153,6 +154,7 @@ describe("shell lazy events", () => {
|
||||
Object.defineProperty(shell, "approvalOverlay", {
|
||||
get: () => (customElements.get(element.tagName) ? { show } : undefined),
|
||||
});
|
||||
stubRenderedWhenDefined(shell);
|
||||
|
||||
await withConnectedShell(shell, async () => {
|
||||
shell.openApprovals();
|
||||
|
||||
@@ -254,6 +254,19 @@ describeControlUiE2e("Control UI live device scope upgrade", () => {
|
||||
expect(
|
||||
await mobile.page.locator(".content > openclaw-device-scope-upgrade-banner").count(),
|
||||
).toBe(0);
|
||||
// Burn in the one-time dialog open: its first render fetches glyph
|
||||
// subsets whose arrival reflows fractional text metrics document-wide,
|
||||
// which would otherwise register as sub-pixel drift the open never
|
||||
// caused. The stability assertion below compares settled layouts.
|
||||
await status.click();
|
||||
await mobile.page.locator("openclaw-modal-dialog.scope-upgrade-details-dialog").waitFor();
|
||||
await mobile.page.getByRole("button", { name: "Close limited access details" }).click();
|
||||
await mobile.page
|
||||
.locator("openclaw-modal-dialog.scope-upgrade-details-dialog")
|
||||
.waitFor({ state: "detached" });
|
||||
await status.waitFor();
|
||||
await mobile.page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const titleTopBefore = (await title.boundingBox())?.y;
|
||||
await captureProof(mobile.page, "mobile-automations-shell-status.png");
|
||||
|
||||
@@ -309,6 +322,14 @@ describeControlUiE2e("Control UI live device scope upgrade", () => {
|
||||
const status = desktop.page.locator(".scope-upgrade-shell-status");
|
||||
await title.waitFor();
|
||||
await status.waitFor();
|
||||
// Same burn-in as the mobile section: the first popover render fetches
|
||||
// glyph subsets whose arrival reflows fractional text metrics.
|
||||
await status.click();
|
||||
await desktop.page.locator(".scope-upgrade-details-popover").waitFor();
|
||||
await desktop.page.getByRole("button", { name: "Close limited access details" }).click();
|
||||
await status.waitFor();
|
||||
await desktop.page.evaluate(() => document.fonts.ready);
|
||||
|
||||
const titleTopBefore = (await title.boundingBox())?.y;
|
||||
await captureProof(desktop.page, "desktop-automations-shell-status.png");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user