Control UI: guard deferred bootstrap connect after disconnect

This commit is contained in:
Tak Hoffman
2026-03-04 23:37:17 -06:00
parent a5e9f3ed42
commit 9b0ba7bfbb
4 changed files with 33 additions and 3 deletions

View File

@@ -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<void>((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();
});
});

View File

@@ -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<typeof handleDisconnected>[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);

View File

@@ -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<typeof applySettingsFromUrl>[0]);
@@ -49,9 +51,12 @@ export function handleConnected(host: LifecycleHost) {
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);
attachThemeListener(host as unknown as Parameters<typeof attachThemeListener>[0]);
window.addEventListener("popstate", host.popStateHandler);
void bootstrapReady.finally(() =>
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]),
);
void bootstrapReady.finally(() => {
if (host.connectGeneration !== connectGeneration) {
return;
}
connectGateway(host as unknown as Parameters<typeof connectGateway>[0]);
});
startNodesPolling(host as unknown as Parameters<typeof startNodesPolling>[0]);
if (host.tab === "logs") {
startLogsPolling(host as unknown as Parameters<typeof startLogsPolling>[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<typeof stopNodesPolling>[0]);
stopLogsPolling(host as unknown as Parameters<typeof stopLogsPolling>[0]);

View File

@@ -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();