Skip duplicate handoff exchange under React Strict Mode#10
Conversation
The bootstrap effect in useManagedAuthSession calls exchangeHandoffCode
on mount with no "already started" guard. Under React 18+ Strict Mode,
effects run mount → cleanup → mount in dev: the first mount consumes
the handoff code server-side, and the second mount re-fires the
exchange with an already-consumed code. The component lands in the
error state and fires onError("Failed to start session"), even though
auth would have worked fine.
The existing ``cancelled`` flag only stops React state updates; it
can't unsend the HTTP request that consumed the code on the first
mount. Add a useRef guard keyed on (sessionId, handoffCode) — same
pattern as the existing callbackFiredRef — so the second mount
short-circuits while a genuine prop change still re-runs the effect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Firetiger deploy monitoring skipped This PR didn't match the auto-monitor filter configured on your GitHub connection:
Reason: PR modifies managed auth client library (packages/managed-auth-react), not kernel API endpoints or Temporal workflows as specified in the filter. To monitor this PR anyway, reply with |
Cursor flagged the original guard: under Strict Mode's
mount → cleanup → mount cycle, the cleanup flipped the closure-local
``cancelled`` to true while the second mount short-circuited at the
new ref guard. The first mount's in-flight exchange would then
resolve, read ``cancelled === true``, and skip its own ``setJwt``
call — leaving the component silently stuck with jwt === null after
the server had already consumed the handoff code. That's strictly
worse than the original bug (visible error → invisible dead state).
Replace the closure-local flag with a ref-identity check. Each
distinct (sessionId, handoffCode) gets a fresh ``{ key }`` object on
``exchangeRef``; the in-flight async reads the ref and bails iff the
ref has been *replaced* (genuine prop change). A Strict Mode re-mount
sees the same key and returns early without touching the ref — so
when the first mount's async resolves, ``exchangeRef.current === ref``
still holds and the JWT is committed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Good catch @cursor — you're right, that's strictly worse than the original bug (visible error → invisible dead state). Pushed 90c173b to fix it. The closure-local const exchangeRef = useRef<{ key: string } | null>(null);
useEffect(() => {
const exchangeKey = `${sessionId}::${handoffCode}`;
if (exchangeRef.current?.key === exchangeKey) return; // Strict Mode re-mount → skip
const ref = { key: exchangeKey }; // fresh identity per real exchange
exchangeRef.current = ref;
(async () => {
const token = await exchangeHandoffCode(...);
if (exchangeRef.current !== ref) return; // stale only if ref was *replaced*
setJwt(token);
...
})();
return () => { stopPolling(); }; // no longer flips cancelled
}, [sessionId, handoffCode]);Walking the four cases:
Cleanup no longer needs to invalidate the in-flight call — staleness is detected by ref-identity, not by a flag. Timing note for the Strict Mode case: the synthetic unmount/remount runs synchronously within the same React commit, before any microtasks. So |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 90c173b. Configure here.
Cursor flagged two correct bugs in 90c173b: 1. The short-circuit ``return;`` on the Strict Mode second mount returned undefined, replacing the first mount's cleanup — so when the component actually unmounted later, ``stopPolling`` never ran and any started interval/timeout leaked. 2. Without the closure-local ``cancelled`` flag, a real unmount no longer signaled the in-flight async to stop. The ref-identity check only caught a *prop change*, not unmount, so the async kept firing ``setState``/``startPolling`` against a dead component. Both fix together by adding ``active: boolean`` to the ref object. Cleanup flips it false; the matching-key remount flips it back true. The async checks both ``exchangeRef.current !== ref`` (stale identity) and ``!ref.active`` (real unmount) before each ``setState``. And the short-circuit path now returns the same cleanup function so React preserves it across the synthetic unmount/remount. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Both real bugs — pushed 10ada60. Added const exchangeKey = `${sessionId}::${handoffCode}`;
const cleanup = () => {
if (exchangeRef.current?.key === exchangeKey) {
exchangeRef.current.active = false;
}
stopPolling();
};
if (exchangeRef.current?.key === exchangeKey) {
exchangeRef.current.active = true; // Strict Mode remount of same exchange
return cleanup; // ← key fix: cleanup preserved, not dropped
}
const ref = { key: exchangeKey, active: true };
exchangeRef.current = ref;
(async () => {
const token = await exchangeHandoffCode(...);
if (exchangeRef.current !== ref || !ref.active) return; // unmount OR prop change
setJwt(token);
...
})();
return cleanup;Walking the cases:
Both |
masnwilliams
left a comment
There was a problem hiding this comment.
fix itself looks correct and i've verified locally — strict-mode remount no longer double-exchanges, real unmount stops polling, prop-change still re-exchanges. happy to approve once the comment nits below are addressed.
1. drop the review-tool reference
in useManagedAuthSession.ts the bootstrap-effect comment includes:
The fix has three moving parts (Cursor #10 review iterations):
please remove the parenthetical. review-tooling/process references don't belong in published package source — they don't help a future reader and they leak context about how the PR was reviewed. the three numbered invariants underneath are the load-bearing part and stand on their own.
2. tighten the comment blocks
both comments (the exchangeRef declaration and the effect-body comment) are doing real work explaining a non-obvious invariant, so i don't want to lose them — but they're ~30% longer than they need to be. specifically:
- the
exchangeRefblock has a paragraph describing whatkeyandactivemean that largely restates what the names already convey. the why (strict mode mount→cleanup→mount, can't use closure flag) is what's worth keeping. - the effect-body block's prose intro before the numbered list duplicates the ref-declaration block. one of the two should carry the strict-mode explanation; the other can just point at the invariants.
aim for: one short paragraph stating the problem + the three numbered invariants. no need to re-explain strict mode twice.
no other changes requested. ship it once these are in and i'll approve.
- Drop the parenthetical pointing at the review tool — process refs don't belong in published source. - Collapse the exchangeRef declaration block to one short sentence naming the fields; defer the invariants to the effect comment so the strict-mode rationale lives in exactly one place. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks @masnwilliams — both addressed in 26e1885.
Typecheck and build clean. Ready for re-review whenever you have a minute. |
masnwilliams
left a comment
There was a problem hiding this comment.
comment cleanup looks good — single source of truth on the strict-mode explanation and the review-tool reference is gone. fix verified locally. lgtm 🚢

Summary
The bootstrap effect in
useManagedAuthSessioncallsexchangeHandoffCode(sessionId, handoffCode)on mount with no "already started" guard. Under React 18+ Strict Mode, effects run mount → cleanup → mount in dev:POST /auth/connections/:id/exchange— server consumes the handoff code and returns the JWT.cancelled = true, which stops React state updates but can't unsend the HTTP request.setUIState("error")→onError({ message: "Failed to start session" }), even though auth would have worked fine on the first mount.The component already uses this pattern (
callbackFiredRef) to keeponSuccess/onErrorone-shot — this PR extends the same idea to the bootstrap exchange itself, keyed on(sessionId, handoffCode)so a genuine prop change still re-runs the effect.Why a ref instead of server-side idempotency
Either fixes it; happy to do the alternative if you'd prefer:
Changes
packages/managed-auth-react/src/session/useManagedAuthSession.ts: addexchangedKeyRef(mirrorscallbackFiredRef), short-circuit the effect when the same(sessionId, handoffCode)pair has already started.patch).Verification
bun run typecheck✅bun run build✅ (no behavior change in prod — Strict Mode double-invocation only fires in dev)Context
We're integrators of
@onkernel/managed-auth-react. Hit this when re-enabling Strict Mode in our Next.js app — every local Managed Auth test failed with "Failed to start session" on the second mount, even though the first exchange succeeded. Currently working around it by togglingreactStrictMode: falsebefore testing, which is the kind of thing that's easy to commit by accident. Flagged with the team via Slack, who pointed us here.Note
Medium Risk
Touches managed-auth session bootstrap logic; a mistake could prevent session initialization or leave polling running, though the change is narrowly scoped to guarding duplicate init in dev Strict Mode.
Overview
Prevents
useManagedAuthSessionfrom runningexchangeHandoffCodetwice for the same(sessionId, handoffCode)during React 18+ Strict Mode’s mount→cleanup→mount cycle, avoiding false "Failed to start session" errors.Replaces the effect’s closure
cancelledflag with a ref-tracked(key, active)guard so in-flight exchanges can still commit on Strict Mode remounts, while real unmounts stop polling and async state updates. Adds a patch changeset for@onkernel/managed-auth-react.Reviewed by Cursor Bugbot for commit 26e1885. Bugbot is set up for automated code reviews on this repo. Configure here.