From 9b0ba7bfbb74c3f1480048caff9a28e1d25fdfb8 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:37:17 -0600 Subject: [PATCH] Control UI: guard deferred bootstrap connect after disconnect --- ui/src/ui/app-lifecycle-connect.node.test.ts | 21 ++++++++++++++++++++ ui/src/ui/app-lifecycle.node.test.ts | 2 ++ ui/src/ui/app-lifecycle.ts | 12 ++++++++--- ui/src/ui/app.ts | 1 + 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/ui/src/ui/app-lifecycle-connect.node.test.ts b/ui/src/ui/app-lifecycle-connect.node.test.ts index 43a4d42480e..0e0c425bee9 100644 --- a/ui/src/ui/app-lifecycle-connect.node.test.ts +++ b/ui/src/ui/app-lifecycle-connect.node.test.ts @@ -41,6 +41,7 @@ function createHost() { return { basePath: "", client: null, + connectGeneration: 0, connected: false, tab: "chat", assistantName: "OpenClaw", @@ -79,4 +80,24 @@ describe("handleConnected", () => { await Promise.resolve(); expect(connectGatewayMock).toHaveBeenCalledTimes(1); }); + + it("skips deferred connect when disconnected before bootstrap resolves", async () => { + let resolveBootstrap!: () => void; + loadBootstrapMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveBootstrap = resolve; + }), + ); + connectGatewayMock.mockReset(); + const host = createHost(); + + handleConnected(host as never); + expect(connectGatewayMock).not.toHaveBeenCalled(); + + host.connectGeneration += 1; + resolveBootstrap(); + await Promise.resolve(); + + expect(connectGatewayMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/src/ui/app-lifecycle.node.test.ts b/ui/src/ui/app-lifecycle.node.test.ts index 13fccdd8679..b15a13eb069 100644 --- a/ui/src/ui/app-lifecycle.node.test.ts +++ b/ui/src/ui/app-lifecycle.node.test.ts @@ -5,6 +5,7 @@ function createHost() { return { basePath: "", client: { stop: vi.fn() }, + connectGeneration: 0, connected: true, tab: "chat", assistantName: "OpenClaw", @@ -35,6 +36,7 @@ describe("handleDisconnected", () => { handleDisconnected(host as unknown as Parameters[0]); expect(removeSpy).toHaveBeenCalledWith("popstate", host.popStateHandler); + expect(host.connectGeneration).toBe(1); expect(host.client).toBeNull(); expect(host.connected).toBe(false); expect(disconnectSpy).toHaveBeenCalledTimes(1); diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index cc8a2f2cdb4..815947d6972 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -22,6 +22,7 @@ import type { Tab } from "./navigation.ts"; type LifecycleHost = { basePath: string; client?: { stop: () => void } | null; + connectGeneration: number; connected?: boolean; tab: Tab; assistantName: string; @@ -42,6 +43,7 @@ type LifecycleHost = { }; export function handleConnected(host: LifecycleHost) { + const connectGeneration = ++host.connectGeneration; host.basePath = inferBasePath(); const bootstrapReady = loadControlUiBootstrapConfig(host); applySettingsFromUrl(host as unknown as Parameters[0]); @@ -49,9 +51,12 @@ export function handleConnected(host: LifecycleHost) { syncThemeWithSettings(host as unknown as Parameters[0]); attachThemeListener(host as unknown as Parameters[0]); window.addEventListener("popstate", host.popStateHandler); - void bootstrapReady.finally(() => - connectGateway(host as unknown as Parameters[0]), - ); + void bootstrapReady.finally(() => { + if (host.connectGeneration !== connectGeneration) { + return; + } + connectGateway(host as unknown as Parameters[0]); + }); startNodesPolling(host as unknown as Parameters[0]); if (host.tab === "logs") { startLogsPolling(host as unknown as Parameters[0]); @@ -66,6 +71,7 @@ export function handleFirstUpdated(host: LifecycleHost) { } export function handleDisconnected(host: LifecycleHost) { + host.connectGeneration += 1; window.removeEventListener("popstate", host.popStateHandler); stopNodesPolling(host as unknown as Parameters[0]); stopLogsPolling(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 2576196e2e5..799ea9100c6 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -111,6 +111,7 @@ function resolveOnboardingMode(): boolean { export class OpenClawApp extends LitElement { private i18nController = new I18nController(this); clientInstanceId = generateUUID(); + connectGeneration = 0; @state() settings: UiSettings = loadSettings(); constructor() { super();