Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
62 changes: 54 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,20 @@ export function useManagedAuthSession(
success: false,
error: false,
});
// Tracks the in-flight (or completed) bootstrap exchange so the second
// mount of a React 18+ Strict Mode mount → cleanup → mount cycle can
// adopt the result of the first mount's exchange instead of refiring
// it with a now-consumed handoff code.
//
// ``key`` identifies *which* (sessionId, handoffCode) exchange this is
// (so a real prop change replaces the object and stales the previous
// async by identity). ``active`` is flipped to false by cleanup and
// back to true by the matching-key remount — so the in-flight async
// can distinguish a Strict Mode synthetic unmount/remount (active goes
// false then true again before resolve) from a real unmount (stays
// false), and stop mid-flight calls or post-unmount ``startPolling``
// from acting on a dead component.
const exchangeRef = useRef<{ key: string; active: boolean } | null>(null);

const stopPolling = useCallback(() => {
if (pollDelayRef.current) {
Expand Down Expand Up @@ -167,18 +181,53 @@ 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 the original code refired ``exchangeHandoffCode``
// on the second mount and landed in the error state. The fix has
// three moving parts (Cursor #10 review iterations):
//
// 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 also
// 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 +262,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 +273,7 @@ export function useManagedAuthSession(
}
}
})();
return () => {
cancelled = true;
stopPolling();
};
return cleanup;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, handoffCode]);

Expand Down
Loading