chore(web): extract App.tsx's connection lifecycle into useConnectionLifecycle - #2169
Conversation
…Lifecycle Phase 2 step 3 of the App.tsx decomposition (#2154, under #2129/#2126). Moves the whole connection cluster out of App.tsx and into clients/web/src/hooks/useConnectionLifecycle.ts: setupClientForServer (the single place a connection is configured), onToggleConnection, onDisconnect, onReauthenticateFromBanner, the deep-link auto-connect effect and its three phase latches, the connected/teardown/disconnect effects, connectErrorMessage, and the session-scoped reset a disconnect triggers. Handshake telemetry rides along as useHandshakeTelemetry, split out only because useOAuthRecovery runs first and stamps the same ref. No behavior change. App.tsx goes 3,039 -> 2,412 lines. Per #2129, resetSessionScopedUiState takes a narrow SessionResetSurface - one reset() per owner - rather than every phase-1 setter, so the hook does not end up depending on everything phase 1 extracted. The two log-level operations stay App-owned because the connect path seeds the same state. Closes #2154 Signed-off-by: cliffhall <cliff@futurescale.com>
There was a problem hiding this comment.
Pull request overview
Extracts web connection lifecycle logic from App.tsx into a dedicated, tested hook without intended behavior changes.
Changes:
- Adds connection lifecycle and handshake telemetry hooks.
- Wires lifecycle dependencies through narrow reset and OAuth surfaces.
- Adds comprehensive hook tests.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
clients/web/src/hooks/useConnectionLifecycle.ts |
Implements connection setup, teardown, deep-linking, and reauthentication. |
clients/web/src/hooks/useConnectionLifecycle.test.tsx |
Tests lifecycle, OAuth, telemetry, and deep-link behavior. |
clients/web/src/App.tsx |
Replaces inline lifecycle logic with the new hooks. |
Suppressed comments (1)
clients/web/src/hooks/useConnectionLifecycle.ts:947
- Discarding this promise can also create an unhandled rejection:
onToggleConnectionmay throw during client/store construction before reaching its internaltry. Terminate it with a catch and expose the failure instead of leaving the banner retry silent.
void onToggleConnection(serverId);
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
- Terminate the two banner-path `onToggleConnection` calls through a `retryConnect` helper. The toggle handles every handshake failure, but client construction runs ahead of its try/catch, so a throw there escaped into a discarded promise with the banner already dismissed. Recorded on `connectErrorMessage` rather than toasted, matching the deep-link phases' own catch, so the move stays inert. - Add the unsettled-`/api/config` race test: a connect that starts before the sandbox answer is held at the gate and builds from the URL as of the release, which is what `sandboxUrlRef` exists for. - Correct the harness's `client` doc, which described a last-constructed fallback the harness never implemented. Signed-off-by: cliffhall <cliff@futurescale.com>
|
Copilot round 1 — all three addressed in 005e4c6. Mirroring here since inline threads go outdated once the fix is pushed. 1. 2. 3. One deliberate call on #3: it records the failure on Coverage after the round: 55 tests, 100% statements / lines / functions, 99.35% branches. Gate green — all four client validates, the coverage gate, |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
clients/web/src/hooks/useConnectionLifecycle.ts:283
connectedServerIdis completely derived fromconnectionStatusandactiveServerId, so synchronizing it in an effect renders one stale frame and requires suppressingreact-hooks/set-state-in-effect. Remove this state/effect pair and derive the returned value during render (connectionStatus === "connected" ? activeServerId : undefined).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
setConnectedServerId(
connectionStatus === "connected" ? activeServerId : undefined,
);
clients/web/src/hooks/useConnectionLifecycle.test.tsx:920
- This test never hydrates the server row or asserts a connection, so it does not verify the “still connects” behavior in its name. Rerender with the matching row after the rejected add and wait for
connectSpy; otherwise a regression that permanently stops after the 409 remains green.
await waitFor(() => expect(addServerImpl).toHaveBeenCalled());
expect(h.api().connectErrorMessage).toBeUndefined();
});
… phase Copilot round 2: the "swallows the already-exists race and still connects" test only asserted that nothing was recorded, so a deep link that swallowed the 409 and then stopped forever would have stayed green. Rerender with the hydrated row and wait for the connect. Signed-off-by: cliffhall <cliff@futurescale.com>
|
Copilot round 2 — no new comments on the changed code; two previously-missed findings, both answered.
That effect is pre-existing and moved inert; the Gate green on c37e795, and |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/hooks/useConnectionLifecycle.ts:85
- This synchronizes local state from the
connectionStatusprop in a passive effect, so the connected frame can render the previous session's latency before the effect corrects it. The web state convention requires render-time adjustment viauseValueChange(or restructuring telemetry around the connection callbacks); keep any ref mutation out of theuseValueChangecallback because it runs during render.
This issue also appears on line 279 of the same file.
useEffect(() => {
if (
connectionStatus === "connected" &&
connectStartRef.current !== undefined
) {
setLatencyMs(Date.now() - connectStartRef.current);
connectStartRef.current = undefined;
} else if (connectionStatus !== "connected") {
setLatencyMs(undefined);
}
clients/web/src/hooks/useConnectionLifecycle.ts:284
connectedServerIdis exactly derived fromconnectionStatusandactiveServerId, but this effect stores that derivation after paint and suppresses the rule that detects the stale frame. Remove the state/effect and derive the returned value directly (connectionStatus === "connected" ? activeServerId : undefined); this also removes the lint suppression.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-existing status-driven effect, unmasked when this component dropped below the React Compiler's bail-out (#2161)
setConnectedServerId(
connectionStatus === "connected" ? activeServerId : undefined,
);
}, [connectionStatus, activeServerId]);
|
Copilot round 3 — no new comments again. Two suppressed items: one repeat, one new. No code change this round.
The stated failure does not occur. The claim is that "the connected frame can render the previous session's latency before the effect corrects it". It cannot: the And Worth noting this effect carries no Both remaining items are answered — one tracked, one declined on the merits — and there are no actionable comments outstanding. |
Closes #2154
Phase 2 step 3 of the
App.tsxdecomposition — see #2129 for the phase, #2126 for the whole effort. Step 2 (#2153,useOAuthRecovery) has merged, so this is cut fromv2/mainrather than stacked.What moved
Everything about bringing a session up and taking it down now lives in
clients/web/src/hooks/useConnectionLifecycle.ts:setupClientForServer(the heaviest single function in the file — the one place a connection is configured) and the layout effect that publishes it to the/oauth/callbackrebuildonToggleConnection,onDisconnect,onReauthenticateFromBannerdisconnect-event effectsconnectErrorMessage/recordConnectErrorresetSessionScopedUiStatesandboxUrlRefuseHandshakeTelemetry(connectStartRef+latencyMs) is exported from the same file but called separately. It is listed in step 3's scope and belongs here, but it cannot live inside the hook:useOAuthRecoverystamps the same ref on its/oauth/callbackreconnect and runs first, so the ref has to exist earlier than the hook that otherwise owns it. Keeping both halves in the connection file was the better of the two options — the alternative was leaving them loose inApp.tsx.No behavior change.
App.tsxgoes 3,039 → 2,412 lines.The
reset()surface #2129 asked forresetSessionScopedUiStatereaches into most of phase 1's UI state on disconnect. Rather than handing the hook every setter — which would have made it depend on everything phase 1 extracted, decoupling nothing — it takes a singleSessionResetSurface: onereset()per owner (clearResultPanels,resetTabUiState,resetTaskProgress,resetOAuthRecoveryState,resetLogLevels,closeConnectionInfoModal).The two log-level operations stay App-owned deliberately:
currentLogLevel/modernLogLevelare App state that the connect path also seeds, so the hook getsresetLogLevels()for the disconnect edge andseedModernLogLevel(settings)for the construction edge, and neither reaches a raw setter.Coverage
useConnectionLifecycle.test.tsx— 52 tests, 100% statements / lines / functions, 99.34% branches.InspectorClientis deliberately not mocked.setupClientForServerexists to translate a server entry into that constructor's options, so a stand-in would leave the whole translation unverified; the network-touching methods are spied on the prototype instead, and the assertions read back through the client's own getters (getServerSettings,getRoots,getSessionId).One 401 detail worth noting for review:
isUnauthorizedErrorkeys offstatus/code, not the message, so the fixture isObject.assign(new Error("Unauthorized"), { status: 401 }). A plainError("HTTP 401")takes the non-auth arm and every OAuth test would have passed for the wrong reason — it did, on the first run.Verification
npm run cigreen from the repo root, all three web smokes included (plus the Firefox pass).No UI change, so no screenshots — the moves are inert and the three web smokes (
smoke:web:browser,smoke:web:app,smoke:web:elicit) are the end-to-end backstop for that.