From 6060d76b44b6c5acc90f3d2119a76020c2a44553 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 15 Jun 2026 13:55:29 +0800 Subject: [PATCH] fix(auth): close two regressions from the resume refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical: play-page bootstrap infinite loop when AUTH_ENABLED and no resume snapshot. The refactor changed the gate from `if (AUTH_ENABLED && hasSnapshot)` to `if (AUTH_ENABLED)`, so any snapshot-less /play entry (the common case — normal card/preset/custom start) entered the async branch, got null from consumeResumeSnapshot, bumped retryBootstrap, and re-ran the effect forever. Restored the peek-before-await: only enter the async resume branch when a snapshot actually exists; otherwise fall straight through to normal bootstrap. Verified via control-flow simulation across all three paths (no snapshot / snapshot + signed in / snapshot + not signed in). Major: homepage auto-started a game after a bare OAuth login. Routing persistPendingStart through AuthModal.onBeforeOAuth fired it for every OAuth redirect, including bare logins via UserChip / StyleModal onRequireAuth (where pendingAction is null and the user only wanted to sign in). Guarded the snapshot on `pendingAction === "start"` so only the mid-start flow persists; bare logins no longer resurrect the form and auto-start on return. --- app/page.tsx | 14 ++++++++++---- app/play/page.tsx | 18 +++++++++++++----- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 97db554..2833abf 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1992,10 +1992,16 @@ export default function HomePage() { start(); } }} - // Snapshot the form the instant the OAuth redirect begins — the - // round-trip unmounts the page and discards in-memory state. Fires - // only for Google/GitHub (signInWithOAuth), not OTP. - onBeforeOAuth={persistPendingStart} + // + // Only snapshot when the user is mid-start: the OAuth redirect also + // fires for bare logins (UserChip / StyleModal onRequireAuth), where + // the user just wants to sign in — not kick off a game. Guarding on + // pendingAction keeps bare logins from auto-starting a session on + // return. (start() sets pendingAction="start" right before opening + // this modal.) + onBeforeOAuth={() => { + if (pendingAction === "start") persistPendingStart(); + }} /> )} diff --git a/app/play/page.tsx b/app/play/page.tsx index 00db2d7..c2db30c 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -1478,16 +1478,24 @@ function PlayInner() { // re-bootstrapping from `?card=…` (which would restart the story). OTP // login never writes a snapshot — its onSuccess retry keeps state // in-memory. - if (AUTH_ENABLED) { - // Let the async resume run; on failure (no snapshot / not signed in / - // corrupt) it relinquishes the slot so the normal bootstrap below - // re-runs. Either way return here — the sync body must not run while the - // OAuth return is being reconciled. + // + // Peek before awaiting: when there's no snapshot (the common case — + // normal card/preset/custom entry), fall straight through to the + // bootstrap below. Only when a snapshot exists do we enter the async + // gate, which itself removes the entry. This keeps the no-snapshot path + // off the retryBootstrap re-trigger loop entirely. + if ( + AUTH_ENABLED && + sessionStorage.getItem(PLAY_RESUME_KEY) !== null + ) { void (async () => { const snap = await consumeResumeSnapshot( PLAY_RESUME_KEY, ); if (!snap) { + // Snapshot existed but user isn't signed in / payload corrupt → + // consumeResumeSnapshot already removed it. Relinquish the slot so + // the normal bootstrap below re-runs on the next effect cycle. startedRef.current = false; setRetryBootstrap((n) => n + 1); return;