Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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.
17 changes: 17 additions & 0 deletions packages/managed-auth-react/src/session/useManagedAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export function useManagedAuthSession(
success: false,
error: false,
});
// Guards against React 18+ Strict Mode's dev-only mount → cleanup → mount
// double-invocation. The handoff code is one-shot on the server, so the
// second mount's exchange call would always 4xx and surface as
// "Failed to start session" even when auth would have worked fine.
// Stores the (sessionId, handoffCode) pair so a genuine prop change still
// triggers a fresh exchange; only repeats of the same pair are skipped.
const exchangedKeyRef = useRef<string | null>(null);

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

useEffect(() => {
// Strict-mode-safe one-shot init: skip the duplicate mount that would
// re-exchange an already-consumed handoff code. ``cancelled`` alone
// can't help here — it stops the second render's setState calls but
// can't unsend the HTTP request that consumed the code on the first
// mount. Same idea as ``callbackFiredRef`` above, scoped per
// (sessionId, handoffCode) so a real prop change still re-runs.
const exchangeKey = `${sessionId}::${handoffCode}`;
if (exchangedKeyRef.current === exchangeKey) return;
exchangedKeyRef.current = exchangeKey;
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

let cancelled = false;
(async () => {
try {
Expand Down