From 8a0e3b64229ecc68eb8576abb49d038e77c862b4 Mon Sep 17 00:00:00 2001 From: Alex Knight Date: Sun, 3 May 2026 21:41:08 +1000 Subject: [PATCH] test(ui): clean up navigation teardown (#76625) --- ui/src/ui/app-lifecycle.ts | 23 ++++++++++ ui/src/ui/control-ui-performance.ts | 3 +- ui/src/ui/navigation.browser.test.ts | 6 +-- ui/src/ui/test-helpers/app-mount.ts | 66 +++++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 8 deletions(-) diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 784b9101e59..3ba28f2de9a 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -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[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[0]); stopLogsPolling(host as unknown as Parameters[0]); stopDebugPolling(host as unknown as Parameters[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; diff --git a/ui/src/ui/control-ui-performance.ts b/ui/src/ui/control-ui-performance.ts index bec55728eba..8a2ba8365f3 100644 --- a/ui/src/ui/control-ui-performance.ts +++ b/ui/src/ui/control-ui-performance.ts @@ -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", { diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index bb87ba40e9e..b6021eed95e 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -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); } diff --git a/ui/src/ui/test-helpers/app-mount.ts b/ui/src/ui/test-helpers/app-mount.ts index 62bad0732b9..4b11da1834f 100644 --- a/ui/src/ui/test-helpers/app-mount.ts +++ b/ui/src/ui/test-helpers/app-mount.ts @@ -41,9 +41,69 @@ function createMatchMediaMock(width: number) { }; }); } + +const mountedApps = new Set(); + +function collectMountedApps() { + return new Set([ + ...mountedApps, + ...document.querySelectorAll("openclaw-app"), + ]); +} + +function nextMicrotask() { + return Promise.resolve(); +} + +function nextTimer() { + return new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +function nextFrame() { + return new Promise((resolve) => { + if (typeof window.requestAnimationFrame !== "function") { + window.setTimeout(resolve, 0); + return; + } + window.requestAnimationFrame(() => resolve()); + }); +} + +async function waitForAppUpdates(apps: Iterable) { + for (const app of apps) { + await app.updateComplete; + } +} + +async function drainAppWork(apps: Iterable) { + 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((resolve) => setTimeout(resolve, 0)); + await nextTimer(); + await nextMicrotask(); }); }