test(ui): clean up navigation teardown (#76625)

This commit is contained in:
Alex Knight
2026-05-03 21:41:08 +10:00
committed by GitHub
parent 5ab4e6f9d9
commit 8a0e3b6422
4 changed files with 90 additions and 8 deletions

View File

@@ -48,6 +48,10 @@ type LifecycleHost = {
logsAutoFollow: boolean;
logsAtBottom: boolean;
logsEntries: unknown[];
chatScrollFrame?: number | null;
chatScrollTimeout?: number | null;
logsScrollFrame?: number | null;
controlUiTabPaintSeq?: number;
popStateHandler: () => void;
topbarObserver: ResizeObserver | null;
};
@@ -79,12 +83,31 @@ export function handleFirstUpdated(host: LifecycleHost) {
observeTopbar(host as unknown as Parameters<typeof observeTopbar>[0]);
}
function cancelHostAnimationFrame(frame: number | null | undefined) {
if (frame != null && typeof window.cancelAnimationFrame === "function") {
window.cancelAnimationFrame(frame);
}
}
function clearHostTimeout(timeout: number | null | undefined) {
if (timeout != null && typeof window.clearTimeout === "function") {
window.clearTimeout(timeout);
}
}
export function handleDisconnected(host: LifecycleHost) {
host.connectGeneration += 1;
host.controlUiTabPaintSeq = (host.controlUiTabPaintSeq ?? 0) + 1;
window.removeEventListener("popstate", host.popStateHandler);
stopNodesPolling(host as unknown as Parameters<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);
stopDebugPolling(host as unknown as Parameters<typeof stopDebugPolling>[0]);
cancelHostAnimationFrame(host.chatScrollFrame);
host.chatScrollFrame = null;
cancelHostAnimationFrame(host.logsScrollFrame);
host.logsScrollFrame = null;
clearHostTimeout(host.chatScrollTimeout);
host.chatScrollTimeout = null;
host.realtimeTalkSession?.stop();
host.realtimeTalkSession = null;
host.realtimeTalkActive = false;

View File

@@ -4,6 +4,7 @@ import type { Tab } from "./navigation.ts";
type ControlUiPerformanceHost = {
tab: Tab;
isConnected?: boolean;
eventLog?: unknown[];
eventLogBuffer?: unknown[];
requestUpdate?: () => void;
@@ -89,7 +90,7 @@ export function scheduleControlUiTabVisibleTiming(
host.requestUpdate?.();
const record = () => {
if (host.controlUiTabPaintSeq !== seq || host.tab !== tab) {
if (host.isConnected === false || host.controlUiTabPaintSeq !== seq || host.tab !== tab) {
return;
}
recordControlUiPerformanceEvent(host, "control-ui.tab.visible", {

View File

@@ -1,12 +1,8 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts";
registerAppMountHooks();
afterEach(() => {
vi.restoreAllMocks();
});
function mountApp(pathname: string) {
return mountTestApp(pathname);
}

View File

@@ -41,9 +41,69 @@ function createMatchMediaMock(width: number) {
};
});
}
const mountedApps = new Set<OpenClawApp>();
function collectMountedApps() {
return new Set<OpenClawApp>([
...mountedApps,
...document.querySelectorAll<OpenClawApp>("openclaw-app"),
]);
}
function nextMicrotask() {
return Promise.resolve();
}
function nextTimer() {
return new Promise<void>((resolve) => window.setTimeout(resolve, 0));
}
function nextFrame() {
return new Promise<void>((resolve) => {
if (typeof window.requestAnimationFrame !== "function") {
window.setTimeout(resolve, 0);
return;
}
window.requestAnimationFrame(() => resolve());
});
}
async function waitForAppUpdates(apps: Iterable<OpenClawApp>) {
for (const app of apps) {
await app.updateComplete;
}
}
async function drainAppWork(apps: Iterable<OpenClawApp>) {
const snapshot = [...apps];
await nextMicrotask();
await waitForAppUpdates(snapshot);
await nextFrame();
await nextMicrotask();
await nextFrame();
await nextMicrotask();
await waitForAppUpdates(snapshot);
await nextTimer();
await nextMicrotask();
await waitForAppUpdates(snapshot);
}
async function cleanupMountedApps() {
const apps = collectMountedApps();
await drainAppWork(apps);
for (const app of apps) {
app.remove();
}
document.body.replaceChildren();
mountedApps.clear();
await drainAppWork(apps);
}
export function mountApp(pathname: string) {
window.history.replaceState({}, "", pathname);
const app = document.createElement("openclaw-app") as OpenClawApp;
mountedApps.add(app);
document.body.append(app);
app.connected = true;
app.requestUpdate();
@@ -96,12 +156,14 @@ export function registerAppMountHooks() {
});
afterEach(async () => {
await cleanupMountedApps();
window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = undefined;
getSafeLocalStorage()?.clear();
getSafeSessionStorage()?.clear();
document.body.innerHTML = "";
await i18n.setLocale("en");
vi.restoreAllMocks();
vi.unstubAllGlobals();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
await nextTimer();
await nextMicrotask();
});
}