fix(web): reset pending OAuth slots during render, not in an effect

`useOAuthRecovery` cleared its two server-scoped pending-OAuth slots — the
deferred re-auth and the step-up prompt — in a `useEffect` keyed on
`activeServerId`. An effect runs after the commit, so a server switch painted
one frame still carrying the previous server's re-auth banner or step-up
prompt: an authorization affordance attached to the wrong server.

Reset both during render with `useValueChange` instead. `activeServerId` is a
primitive, so it satisfies the helper's `Object.is` stability requirement, and
the previous value is read from the `setState` updater argument rather than
from `sessionRef.current`, keeping the render-phase callback pure.

`react-hooks/set-state-in-effect` did not fire on the original because the
effect read the previous value through a ref rather than from a prop or state.

The test records one entry per committed render and asserts both slots are
empty in the first frame after the switch — the pre-existing `waitFor`-based
tests pass against the effect version too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-03 20:08:29 -04:00
commit 2ba25d63e3

View file

@ -54,6 +54,7 @@ import {
import { OAUTH_CALLBACK_PATH } from "../utils/oauthFlow";
import { INSPECTOR_SERVERS_TAB } from "../utils/inspectorTabs";
import type { PendingReauth } from "../utils/pendingReauth";
import { useValueChange } from "./useValueChange";
import {
authRecoveryRestoredMessage,
authRecoveryAbandonedMessage,
@ -318,16 +319,25 @@ export function useOAuthRecovery({
sessionRef.current.pendingReauth = pendingReauth;
});
useEffect(() => {
const pending = sessionRef.current.pendingReauth;
if (pending && pending.serverId !== activeServerId) {
setPendingReauth(null);
}
const stepUp = sessionRef.current.pendingStepUp;
if (stepUp && stepUp.serverId !== activeServerId) {
setPendingStepUp(null);
}
}, [sessionRef, activeServerId]);
// Both slots are server-scoped, so switching servers has to clear whichever
// one belongs to the server we just left. Adjusted **during render** rather
// than in an effect: an effect only runs after the commit, so the frame that
// shows the new server would still paint the previous server's re-auth
// banner or step-up prompt — a modal-grade authorization affordance attached
// to the wrong server (#2223). `activeServerId` is a primitive, so it is
// referentially stable across renders that mean "no change", which is what
// `useValueChange`'s `Object.is` comparison requires. The previous value is
// read from the updater argument rather than from `sessionRef.current`,
// keeping the callback pure — a render can be replayed or abandoned, so it
// must not depend on external mutable state.
useValueChange(activeServerId, () => {
setPendingReauth((current) =>
current && current.serverId !== activeServerId ? null : current,
);
setPendingStepUp((current) =>
current && current.serverId !== activeServerId ? null : current,
);
});
const trySetPendingStepUp = useCallback(
(next: PendingStepUp): boolean => {