diff --git a/.changeset/fix-strict-mode-double-exchange.md b/.changeset/fix-strict-mode-double-exchange.md new file mode 100644 index 0000000..0472c9a --- /dev/null +++ b/.changeset/fix-strict-mode-double-exchange.md @@ -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. diff --git a/packages/managed-auth-react/src/session/useManagedAuthSession.ts b/packages/managed-auth-react/src/session/useManagedAuthSession.ts index 515db26..fe45e64 100644 --- a/packages/managed-auth-react/src/session/useManagedAuthSession.ts +++ b/packages/managed-auth-react/src/session/useManagedAuthSession.ts @@ -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) { @@ -167,7 +172,41 @@ 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( @@ -175,10 +214,10 @@ export function useManagedAuthSession( 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 ( @@ -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); @@ -224,10 +263,7 @@ export function useManagedAuthSession( } } })(); - return () => { - cancelled = true; - stopPolling(); - }; + return cleanup; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, handoffCode]);