fix(auth): close two regressions from the resume refactor

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.
This commit is contained in:
yuanzonghao
2026-06-15 13:55:29 +08:00
parent 8cdeb1592f
commit 6060d76b44
2 changed files with 23 additions and 9 deletions
+10 -4
View File
@@ -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();
}}
/>
)}
</div>
+13 -5
View File
@@ -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<PlayResumeSnapshot>(
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;