Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-strict-mode-double-exchange.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@onkernel/managed-auth-react": patch
---

Guard the bootstrap effect in `useManagedAuthSession` against React 18+ Strict Mode's mount → cleanup → mount double-invocation. Without the guard, the second mount re-fires `exchangeHandoffCode` with a now-consumed handoff code and the component lands in the error state ("Failed to start session") even when auth would have worked. Tracked per `(sessionId, handoffCode)` so a genuine prop change still triggers a fresh exchange.
52 changes: 44 additions & 8 deletions packages/managed-auth-react/src/session/useManagedAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ export function useManagedAuthSession(
success: false,
error: false,
});
// Tracks the in-flight bootstrap exchange. ``key`` identifies which
// (sessionId, handoffCode) pair it belongs to; ``active`` is false
// between cleanup and the matching-key remount. See the effect below
// for the invariants these fields enforce.
const exchangeRef = useRef<{ key: string; active: boolean } | null>(null);

const stopPolling = useCallback(() => {
if (pollDelayRef.current) {
Expand Down Expand Up @@ -167,18 +172,52 @@ export function useManagedAuthSession(
);

useEffect(() => {
let cancelled = false;
// Strict-Mode-safe one-shot init. Under React 18+ Strict Mode in dev,
// effects run mount → cleanup → mount; the handoff code is one-shot
// server-side, so a naive remount refires the exchange against an
// already-consumed code. Three invariants make this safe:
//
// 1. Guard the exchange by ref identity, not a closure-local
// ``cancelled`` flag — a closure flag set by the synthetic
// cleanup would orphan the first mount's in-flight result.
// 2. Track an ``active`` flag on the ref so the async can
// distinguish a real unmount (active stays false) from a
// Strict Mode unmount/remount (active flips false → true
// synchronously before the async resolves).
// 3. Always return the cleanup, even on the short-circuit path —
// React only keeps the most recent effect's cleanup, so a bare
// ``return`` from the second mount would orphan ``stopPolling``
// and leak the interval at real unmount.
const exchangeKey = `${sessionId}::${handoffCode}`;
const cleanup = () => {
if (exchangeRef.current?.key === exchangeKey) {
exchangeRef.current.active = false;
}
stopPolling();
};

if (exchangeRef.current?.key === exchangeKey) {
// Strict Mode remount of the same exchange: cleanup just flipped
// active=false; flip it back so the in-flight async can commit.
// Return the cleanup so a later *real* unmount still stops polling.
exchangeRef.current.active = true;
return cleanup;
}

const ref = { key: exchangeKey, active: true };
exchangeRef.current = ref;

(async () => {
try {
const token = await exchangeHandoffCode(
sessionId,
handoffCode,
options,
);
if (cancelled) return;
if (exchangeRef.current !== ref || !ref.active) return;
setJwt(token);
const initial = await retrieveManagedAuth(sessionId, token, options);
if (cancelled) return;
if (exchangeRef.current !== ref || !ref.active) return;
setState(initial);
const derived = deriveUIState(initial);
if (
Expand Down Expand Up @@ -213,7 +252,7 @@ export function useManagedAuthSession(
setUIState("prime");
}
} catch (err) {
if (cancelled) return;
if (exchangeRef.current !== ref || !ref.active) return;
const message =
err instanceof Error ? err.message : "Failed to start session";
setInitError(message);
Expand All @@ -224,10 +263,7 @@ export function useManagedAuthSession(
}
}
})();
return () => {
cancelled = true;
stopPolling();
};
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, handoffCode]);

Expand Down
Loading