mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
8fe2c1b83c
* 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
136 lines
5.2 KiB
TypeScript
136 lines
5.2 KiB
TypeScript
#!/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);
|
|
});
|