From eaef2c1be796b6f53522c109f20ccea2cbc4c22b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 17:26:37 -0700 Subject: [PATCH] fix(ui): re-arm Model Setup detection when stale route data settles after mount (#127695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Model Setup page could sit at "Checking this Gateway for available AI access…" forever on fresh clients: the route loader ran before the Gateway websocket connected and returned a loading state with a null-client connection, the lazy page module finished importing after hello, and the page mounted with routeData undefined under an already-connected Gateway. When the stale loader result settled a microtask later, willUpdate correctly discarded it, but synchronizeGateway hit the identity-equality early return and nothing ever armed detectTask again - a silent dead-end (fresh headless clients hit it ~100%; warm tabs recovered only because their cached module mounted before hello). Replace the mount-branch routeData-connection comparison with a single self-healing invariant, ensureRouteSettledDetection(), also called from the identity-equality early return: once route data has settled, a page still holding phase "loading" with an idle detect task and a connected, capable Gateway starts detection itself. Guards keep the normal path duplicate-free: undefined routeData means the loader's own detect is still in flight, and hasUpdated defers to willUpdate's seeding since subscriptions fire before the first render. Regression test mounts the page without routeData under a connected Gateway, then delivers the stale pre-connect loader result and asserts exactly one openclaw.setup.detect and a rendered result; it fails on the pre-fix code with zero detect calls. Live-verified with a headless Playwright client against a dev gateway: pre-fix stalls past 45s, fixed build reaches ready in ~5s with a single detect request. --- ui/src/pages/model-setup/model-setup-page.ts | 31 +++++++++++++------ .../model-setup/model-setup-reconnect.test.ts | 29 +++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/ui/src/pages/model-setup/model-setup-page.ts b/ui/src/pages/model-setup/model-setup-page.ts index c6df16bb210c..5dcf42a039d2 100644 --- a/ui/src/pages/model-setup/model-setup-page.ts +++ b/ui/src/pages/model-setup/model-setup-page.ts @@ -1,5 +1,5 @@ import { consume } from "@lit/context"; -import { initialState, Task } from "@lit/task"; +import { initialState, Task, TaskStatus } from "@lit/task"; import { html, type PropertyValues } from "lit"; import { property, state } from "lit/decorators.js"; import type { GatewayBrowserClient } from "../../api/gateway.ts"; @@ -305,15 +305,7 @@ export class ModelSetupPage extends OpenClawLightDomElement { }; if (!this.observedConnection) { this.observedConnection = connection; - if ( - connection.connected && - this.routeData && - (this.routeData.connection.client !== connection.client || - this.routeData.connection.hello !== connection.hello || - this.routeData.connection.agentId !== connection.agentId) - ) { - void this.detect(); - } + this.ensureRouteSettledDetection(); return; } if ( @@ -322,6 +314,7 @@ export class ModelSetupPage extends OpenClawLightDomElement { connection.agentId === this.observedConnection.agentId && connection.connected === this.observedConnection.connected ) { + this.ensureRouteSettledDetection(); return; } this.observedConnection = connection; @@ -344,6 +337,24 @@ export class ModelSetupPage extends OpenClawLightDomElement { } } + // Route data can settle after mount and be discarded as another + // connection's result. Nothing else re-arms detection then, so a loading + // page with a connected, capable Gateway self-heals here instead of + // dead-ending silently. + private ensureRouteSettledDetection(): void { + if ( + !this.hasUpdated || + !this.routeData || + this.pageState.phase !== "loading" || + this.detectTask.status !== TaskStatus.INITIAL + ) { + return; + } + if (this.canUseSetup(this.context.gateway.snapshot.client)) { + void this.detect(); + } + } + private canUseSetup(client: GatewayBrowserClient | null): client is GatewayBrowserClient { const snapshot = this.context.gateway.snapshot; return Boolean( diff --git a/ui/src/pages/model-setup/model-setup-reconnect.test.ts b/ui/src/pages/model-setup/model-setup-reconnect.test.ts index f39b062be26c..3d35a77a6228 100644 --- a/ui/src/pages/model-setup/model-setup-reconnect.test.ts +++ b/ui/src/pages/model-setup/model-setup-reconnect.test.ts @@ -122,6 +122,35 @@ describe("ModelSetupPage Gateway reconnect ownership", () => { vi.restoreAllMocks(); }); + it("recovers when stale route data settles after mounting under a connected gateway", async () => { + const { context, request, runtimeConfig } = createFixture(); + request.mockImplementation(async (method) => + method === "openclaw.setup.detect" ? detection : {}, + ); + const provider = createApplicationContextProvider(context); + const page = document.createElement("openclaw-model-setup-page") as TestModelSetupPage; + provider.append(page); + document.body.append(provider); + await page.updateComplete; + + expect(request).not.toHaveBeenCalled(); + + page.routeData = { + state: { phase: "loading" }, + connection: { client: null, hello: null, agentId: null }, + firstRun: false, + }; + await page.updateComplete; + + await vi.waitFor(() => { + expect( + request.mock.calls.filter(([method]) => method === "openclaw.setup.detect"), + ).toHaveLength(1); + expect(page.querySelector('[data-auth-choice="provider-auth"]')).not.toBeNull(); + }); + runtimeConfig.dispose(); + }); + it("does not expose stale route data when the page mounts during reconnect", async () => { const { client, context, request, runtimeConfig, setGatewayPhase } = createFixture(); request.mockImplementation(async (method) =>