fix(ui): re-arm Model Setup detection when stale route data settles after mount (#127695)

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.
This commit is contained in:
Peter Steinberger
2026-08-21 17:26:37 -07:00
committed by GitHub
parent 5f6de37e20
commit eaef2c1be7
2 changed files with 50 additions and 10 deletions
+21 -10
View File
@@ -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(
@@ -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) =>