chore(web): extract App.tsx's OAuth recovery cluster into useOAuthRecovery (#2153)

Phase 2 step 2 of the App.tsx decomposition (#2129, under #2126). App.tsx goes
from 4,267 to 3,178 lines; no behaviour change.

The new hook owns the step-up slot and its retry operation, the re-auth banner,
the deferred background-tab recovery, the redirect plumbing (webOAuthStorage,
sessionStorageAdapter, onBeforeOAuthRedirect, prepareOAuthRedirect), the
command-scoped recovery wrappers, the four OAuth effects, the abandoned-redirect
check, the /oauth/callback completion, the Connection Info OAuth details, and
the clear/finalize paths. All five once-only latches move with it unchanged.

Three seams could not be a straight move:

- useSessionRef no longer takes pendingStepUp/pendingReauth. They are this
  hook's own state, so App.tsx would have had to hold them purely to hand them
  back; instead the owner mirrors them into the ref in an effect of its own,
  following the shape activeServerName already had.
- setupClientForServer is injected through a ref, because it reads
  onBeforeOAuthRedirect and sessionStorageAdapter out of this hook and so
  cannot be declared before it. The callback effect reads the ref before
  burning its one-shot latch.
- The two clear-OAuth modal adapters and onReauthenticateFromBanner stay in
  App.tsx: they bind App-owned state, and the latter drives a reconnect, which
  step 3 owns.

Dropping ~1,100 lines took App.tsx under the React Compiler's bail-out, so the
compiler-backed react-hooks rules now analyse it and report nine pre-existing
findings that were previously invisible. Each is suppressed with a narrow
justified disable naming #2161, which is filed to resolve them; the one new
finding (exhaustive-deps on setReAuthBanner) is fixed rather than suppressed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hzwzS8UvBsr4yZm7o7sev
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 23:00:12 -04:00
commit ed38a5e5fe
4 changed files with 2962 additions and 1237 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,6 @@ import { describe, it, expect } from "vitest";
import { InspectorClient } from "@inspector/core/mcp/index.js";
import type { ServerEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine } from "../test/renderWithMantine";
import type { PendingReauth } from "../utils/pendingReauth";
import type { PendingStepUp } from "../utils/stepUp";
import {
useSessionRef,
type SessionRef,
@ -27,27 +25,10 @@ const entry = (id: string, name = id): ServerEntry => ({
const client = (): InspectorClient =>
Object.create(InspectorClient.prototype) as InspectorClient;
const stepUp = (serverId: string): PendingStepUp => ({
serverId,
challenge: { reason: "unauthorized" },
authorizationUrl: new URL("https://as.example/authorize"),
source: "tool",
});
const reauth = (serverId: string): PendingReauth => ({
serverId,
challenge: { reason: "unauthorized" },
authorizationUrl: new URL("https://as.example/authorize"),
authKind: "reauth",
source: "tool",
});
const values = (over: Partial<SessionValues> = {}): SessionValues => ({
activeServerId: undefined,
servers: [],
inspectorClient: null,
pendingStepUp: null,
pendingReauth: null,
...over,
});
@ -84,6 +65,8 @@ describe("useSessionRef", () => {
expect(snapshot.activeServerId).toBe("a");
expect(snapshot.servers).toBe(servers);
expect(snapshot.inspectorClient).toBe(c);
// Seeded null and left alone: the two pending-OAuth slots are written by
// their owner, `useOAuthRecovery`, not supplied to this hook (#2153).
expect(snapshot.pendingStepUp).toBeNull();
expect(snapshot.pendingReauth).toBeNull();
});
@ -99,34 +82,39 @@ describe("useSessionRef", () => {
const h = harness(values());
const servers = [entry("a"), entry("b")];
const c = client();
const up = stepUp("a");
const re = reauth("b");
h.update(
values({
activeServerId: "b",
servers,
inspectorClient: c,
pendingStepUp: up,
pendingReauth: re,
}),
);
expect(h.ref().current).toMatchObject({
activeServerId: "b",
servers,
inspectorClient: c,
pendingStepUp: up,
pendingReauth: re,
});
});
it("leaves the owner-written pending slots alone across renders", () => {
const h = harness(values({ activeServerId: "a", servers: [entry("a")] }));
// Stands in for `useOAuthRecovery`'s own mirror effect.
h.ref().current.pendingStepUp = {
serverId: "a",
challenge: { reason: "unauthorized" },
authorizationUrl: new URL("https://as.example/authorize"),
source: "tool",
};
h.update(values({ activeServerId: "a", servers: [entry("a")] }));
expect(h.ref().current.pendingStepUp?.serverId).toBe("a");
});
it("clears a value that becomes null or undefined", () => {
const h = harness(
values({
activeServerId: "a",
servers: [entry("a")],
inspectorClient: client(),
pendingStepUp: stepUp("a"),
pendingReauth: reauth("a"),
}),
);
h.update(values());
@ -134,8 +122,6 @@ describe("useSessionRef", () => {
activeServerId: undefined,
servers: [],
inspectorClient: null,
pendingStepUp: null,
pendingReauth: null,
});
});

View file

@ -24,12 +24,26 @@ export interface SessionSnapshot {
* keeps the value the write replaced (#2089).
*/
inspectorClient: InspectorClient | null;
/**
* The two OAuth-recovery slots. Unlike the fields above they are **not**
* supplied to this hook: they are owned by `useOAuthRecovery`, which mirrors
* them in an effect of its own (#2153). Keeping them off `SessionValues`
* is what lets that hook both own the state and read the ref — App.tsx
* would otherwise have to hold the state purely to hand it back.
*/
pendingStepUp: PendingStepUp | null;
pendingReauth: PendingReauth | null;
}
/** What the caller supplies each render; `activeServerName` is derived. */
export type SessionValues = Omit<SessionSnapshot, "activeServerName">;
/**
* What the caller supplies each render. `activeServerName` is derived, and the
* two pending-OAuth slots are written by their owner — see
* {@link SessionSnapshot}.
*/
export type SessionValues = Omit<
SessionSnapshot,
"activeServerName" | "pendingStepUp" | "pendingReauth"
>;
/** A stable handle onto the latest {@link SessionSnapshot}. */
export type SessionRef = RefObject<SessionSnapshot>;
@ -60,6 +74,8 @@ export type SessionRef = RefObject<SessionSnapshot>;
export function useSessionRef(values: SessionValues): SessionRef {
const ref = useRef<SessionSnapshot>({
activeServerName: undefined,
pendingStepUp: null,
pendingReauth: null,
...values,
});
// Deliberately un-gated: this is a passive mirror, so re-assigning the same
@ -73,8 +89,6 @@ export function useSessionRef(values: SessionValues): SessionRef {
ref.current.activeServerId = values.activeServerId;
ref.current.servers = values.servers;
ref.current.inspectorClient = values.inspectorClient;
ref.current.pendingStepUp = values.pendingStepUp;
ref.current.pendingReauth = values.pendingReauth;
});
return ref;
}