From 99ad8d111ea9e12041b07011742d941c21cc5ad5 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 15 Jun 2026 13:18:56 +0800 Subject: [PATCH 1/4] fix(play): resume in-progress game after OAuth full-page redirect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google/GitHub OAuth is a full-page round-trip that unmounts the app and destroys the in-memory Session (the server is stateless). Returning to /play?card=m0 re-bootstrapped from the first-act JSON, restarting the story from scene 1 — the user lost all progress. OTP login kept state in-memory (no redirect) and was unaffected. Mirror the homepage 89a5c54 OAuth state-loss fix: snapshot the exact scene/beat/visited-beats/orientation/image into sessionStorage just before the redirect, then restore it on mount after the round-trip (verified signed in). Re-resolve the remote image URL to a fresh blob (blob: URLs are revoked on unmount). The pending action that hit the 401 (choice / freeform / background-click) is replayed once the restored state commits, so the player lands exactly where they were headed. Quota fallback drops the user-uploaded style-reference image (~100KB) and retries; voices are kept (continuity over rare quota miss). Failure to restore (corrupt snapshot / not signed in) relinquishes the bootstrap slot and falls back to normal card/preset/custom start instead of a blank loading screen. AuthModal gains an optional onBeforeOAuth callback fired synchronously before signInWithOAuth navigates away (sessionStorage.setItem is sync). --- app/play/page.tsx | 217 +++++++++++++++++++++++++++++++++++++-- components/AuthModal.tsx | 12 ++- 2 files changed, 219 insertions(+), 10 deletions(-) diff --git a/app/play/page.tsx b/app/play/page.tsx index 06ef63c..7bc87a3 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -52,10 +52,39 @@ import type { } from "@infiplot/types"; import { track } from "@/lib/analytics"; import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { createClient as createSupabaseClient } from "@/lib/supabase/client"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; const MUTED_STORAGE_KEY = "infiplot:muted"; +// One-shot snapshot of in-progress game state, written just before an OAuth +// full-page redirect (Google/GitHub) so the play page can resume the exact +// scene/beat after the round-trip. The redirect unmounts the app and destroys +// the in-memory Session (the server is stateless), so without this the play +// page re-bootstraps from `?card=…` and restarts the story. OTP login keeps +// state in-memory (no redirect) and never writes this. Consumed once on mount. +const PLAY_RESUME_KEY = "infiplot:play-resume"; + +// Serializable form of the action intercepted by a 401. `persistPlayResume` +// stashes whichever one is pending into sessionStorage; the deferred-replay +// effect re-dispatches it after `restorePlayResume` commits the restored state. +type PendingResumeAction = + | { kind: "choice"; choice: BeatChoice } + | { kind: "freeform"; text: string } + | { kind: "background-click"; x: number; y: number }; + +// Shape written to sessionStorage[PLAY_RESUME_KEY]. `imageOriginalUrl` is the +// remote CDN URL (never the blob: URL — those are revoked on unmount and won't +// survive the full-page reload); restorePlayResume re-resolves it to a fresh +// blob via getOrCreateBlobUrl. +type PlayResumeSnapshot = { + session: Session; + beatId: string; + visitedBeats: string[]; + orientation: Orientation; + imageOriginalUrl: string; + pendingAction?: PendingResumeAction; +}; // Consecutive silent (no-audio) beats before we surface the BYO-key nudge to a // non-BYO, unmuted player. Set high enough that one transient miss won't trip @@ -618,6 +647,22 @@ function PlayInner() { const [visionClickEnabled, setVisionClickEnabled] = useState(true); const [authModalOpen, setAuthModalOpen] = useState(false); const authResolveRef = useRef<(() => void) | null>(null); + // Serializable description of the action that hit the 401 (choice / freeform + // text / background-click coords), captured alongside the retry closure. An + // OAuth round-trip destroys the closure, but this survives in sessionStorage + // so the exact action can be replayed after game state is restored. + const pendingResumeActionRef = useRef(null); + // Set by restorePlayResume when a snapshot carries a pending action; a + // dedicated effect dispatches it once the restored state has committed + // (phase "ready", session + scene present), then clears it. Mirrors the + // homepage's autoStartPending resume pattern. + const [pendingReplayAction, setPendingReplayAction] = + useState(null); + // Bumped by the OAuth-resume fallback to retrigger the bootstrap effect after + // relinquishing its `startedRef` slot (snapshot consumed but user not signed + // in → run normal card/preset/custom bootstrap instead of leaving a blank + // loading screen). + const [retryBootstrap, setRetryBootstrap] = useState(0); // Top-of-screen progress toast for the gallery / story export pipeline. // null when idle; { done, total, label } while collecting beat audio. const [exportProgress, setExportProgress] = useState< @@ -627,10 +672,18 @@ function PlayInner() { // `retry` re-runs the action that hit the 401, replayed by AuthModal.onSuccess // after the user signs in. Omitted by callers whose path can't actually 401 // (initial load already gated on the homepage, recorded replay is local). + // `action` is the serializable twin of `retry`: same intent, but survives an + // OAuth full-page redirect via sessionStorage so it can be replayed after + // game state is restored (the retry closure itself is destroyed on unmount). const handleAuthError = useCallback( - (e: unknown, retry?: () => void): boolean => { + ( + e: unknown, + retry?: () => void, + action?: PendingResumeAction, + ): boolean => { if (e instanceof AuthRequiredError) { authResolveRef.current = retry ?? null; + pendingResumeActionRef.current = action ?? null; setAuthModalOpen(true); return true; } @@ -639,6 +692,72 @@ function PlayInner() { [], ); + // Snapshot the in-progress game just before an OAuth full-page redirect so + // the play page can resume the exact scene/beat on return. Reads only refs + // (stable across renders), so an empty dep list is safe. Mirrors the + // homepage's persistPendingStart + quota-fallback degradation. + const persistPlayResume = useCallback((): void => { + const sess = sessionRef.current; + const beat = currentBeatRef.current; + const imageOriginalUrl = lastImageOriginalUrlRef.current; + if (!sess || !beat || !imageOriginalUrl) return; + const snap: PlayResumeSnapshot = { + session: sess, + beatId: beat.id, + visitedBeats: [...visitedBeatsRef.current], + orientation: sess.orientation ?? "landscape", + imageOriginalUrl, + pendingAction: pendingResumeActionRef.current ?? undefined, + }; + const tryWrite = (payload: PlayResumeSnapshot): boolean => { + try { + sessionStorage.setItem(PLAY_RESUME_KEY, JSON.stringify(payload)); + return true; + } catch { + return false; // QuotaExceededError — try a lighter payload below + } + }; + if (tryWrite(snap)) return; + // Drop the heaviest data-URL field (user-uploaded style ref, ~100KB) and + // retry. It only affects the Painter on FUTURE scenes, not the resumed + // scene, so losing it degrades gracefully. Voices are deliberately kept — + // preserving voice continuity matters more than the rare quota miss, and a + // typical session (remote-image URLs + a few ~160KB voice refs) fits well + // under sessionStorage's 5MB cap. If still too big, give up: resume is + // disabled for this trip and the page falls back to normal bootstrap. + tryWrite({ ...snap, session: { ...sess, styleReferenceImage: undefined } }); + }, []); + + // Restore an in-progress game from a PLAY_RESUME_KEY snapshot after an OAuth + // round-trip. Re-resolves the remote image URL to a fresh blob (the old blob + // was revoked on unmount), repopulates the runtime refs the handlers read, + // and hands any pending action to the deferred-replay effect. Throws on a + // corrupt snapshot so the caller can fall back to normal bootstrap. + const restorePlayResume = useCallback( + async (snap: PlayResumeSnapshot): Promise => { + const last = snap.session.history[snap.session.history.length - 1]; + if (!last?.scene) throw new Error("resume snapshot missing current scene"); + + setOrientation(snap.orientation); + visitedBeatsRef.current = [...snap.visitedBeats]; + lastImageOriginalUrlRef.current = snap.imageOriginalUrl; + + setSession(snap.session); + setCurrentScene(last.scene); + setCurrentBeatId(snap.beatId); + + const blobUrl = await getOrCreateBlobUrl(snap.imageOriginalUrl); + const ready = waitForImageReady(); + setImageUrl(blobUrl); + await ready; + setPhase("ready"); + track("scene_reached", { scene_index: snap.session.history.length }); + + if (snap.pendingAction) setPendingReplayAction(snap.pendingAction); + }, + [], + ); + const startedRef = useRef(false); const poolRef = useRef>(new Map()); // Accumulator for resolved prefetches across the whole session — every @@ -1359,6 +1478,44 @@ function PlayInner() { if (startedRef.current) return; startedRef.current = true; + // ── OAuth resume ──────────────────────────────────────────────── + // Returning from a Google/GitHub round-trip? The full-page redirect + // destroyed the in-memory Session; if we stashed a snapshot just before + // navigating away (persistPlayResume via AuthModal.onBeforeOAuth) and the + // user is now signed in, restore the exact scene/beat instead of + // 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) { + const resumeRaw = sessionStorage.getItem(PLAY_RESUME_KEY); + if (resumeRaw) { + sessionStorage.removeItem(PLAY_RESUME_KEY); + // Let the async resume run; on failure 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. + void (async () => { + try { + const sb = createSupabaseClient(); + const { data } = await sb.auth.getUser(); + if (!data.user) { + // OAuth failed/not signed in → fall back to normal bootstrap. + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + return; + } + await restorePlayResume( + JSON.parse(resumeRaw) as PlayResumeSnapshot, + ); + } catch { + // Corrupt snapshot / network — relinquish and bootstrap normally. + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + } + })(); + return; + } + } + // 三条进入路径: // ?card= → 首页精选卡,直接从 /home/firstact/{name}.json // 静态文件加载(已在构建期 prebake,免一切引擎调用) @@ -1579,7 +1736,30 @@ function PlayInner() { setError(String(e)); } }); - }, [params, router]); + }, [params, router, retryBootstrap, restorePlayResume]); + + // ── Deferred replay of the action that hit 401 (OAuth resume) ───────── + // After restorePlayResume commits the restored session/scene/beat, dispatch + // the pending action so the player lands exactly where they were headed + // (seamless continuation). Runs once the restored state is interactive, + // then clears the slot. Mirrors the homepage's autoStartPending pattern. + useEffect(() => { + if (!pendingReplayAction) return; + if (phase !== "ready" || !session || !currentScene) return; + const action = pendingReplayAction; + setPendingReplayAction(null); + if (action.kind === "choice") { + onSelectChoice(action.choice); + } else if (action.kind === "freeform") { + void onFreeformInput(action.text); + } else { + void onBackgroundClick({ x: action.x, y: action.y }); + } + // onSelectChoice/onFreeformInput/onBackgroundClick are stable inner + // functions keyed off the restored state; listing them would re-fire on + // every render, so we intentionally scope deps to the readiness gate. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingReplayAction, phase, session, currentScene]); // ── Prefetch on scene entry: L1 + recursive L2/L3 for must-pass ────── useEffect(() => { @@ -1647,6 +1827,7 @@ function PlayInner() { visitedForCurrent: string[], exitLabel: string, retry?: () => void, + action?: PendingResumeAction, ) { const sceneT0 = Date.now(); setPhase("transitioning"); @@ -1706,7 +1887,7 @@ function PlayInner() { setPhase("ready"); return; } - if (!handleAuthError(e, retry)) { + if (!handleAuthError(e, retry, action)) { trackPlayError("scene", e, sceneT0); setError(String(e)); } @@ -1903,8 +2084,13 @@ function PlayInner() { const cached = consumeChoice(poolRef.current, choice.id); if (cached) { - void performSceneTransition(cached, exit, visited, choice.label, () => - onSelectChoice(choice), + void performSceneTransition( + cached, + exit, + visited, + choice.label, + () => onSelectChoice(choice), + { kind: "choice", choice }, ); return; } @@ -1930,8 +2116,13 @@ function PlayInner() { return data; })(); - void performSceneTransition(promise, exit, visited, choice.label, () => - onSelectChoice(choice), + void performSceneTransition( + promise, + exit, + visited, + choice.label, + () => onSelectChoice(choice), + { kind: "choice", choice }, ); } @@ -2037,9 +2228,10 @@ function PlayInner() { visited, decision.freeformAction, () => onFreeformInput(text), + { kind: "freeform", text }, ); } catch (e) { - if (!handleAuthError(e, () => onFreeformInput(text))) { + if (!handleAuthError(e, () => onFreeformInput(text), { kind: "freeform", text })) { trackPlayError("freeform", e, freeformT0); setError(String(e)); } @@ -2146,10 +2338,11 @@ function PlayInner() { visited, decision.intent.freeformAction, () => onBackgroundClick(click), + { kind: "background-click", x: click.x, y: click.y }, ); } } catch (e) { - if (!handleAuthError(e, () => onBackgroundClick(click))) { + if (!handleAuthError(e, () => onBackgroundClick(click), { kind: "background-click", x: click.x, y: click.y })) { trackPlayError("vision", e, visionT0); setError(String(e)); } @@ -2259,13 +2452,16 @@ function PlayInner() { setAuthModalOpen(false); // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; + pendingResumeActionRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); const retry = authResolveRef.current; authResolveRef.current = null; + pendingResumeActionRef.current = null; retry?.(); }} + onBeforeOAuth={persistPlayResume} /> )} @@ -2457,13 +2653,16 @@ function PlayInner() { setAuthModalOpen(false); // User dismissed login — drop the retry, don't re-run the action. authResolveRef.current = null; + pendingResumeActionRef.current = null; }} onSuccess={() => { setAuthModalOpen(false); const retry = authResolveRef.current; authResolveRef.current = null; + pendingResumeActionRef.current = null; retry?.(); }} + onBeforeOAuth={persistPlayResume} /> )} diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx index 8f1cd9b..46df21b 100644 --- a/components/AuthModal.tsx +++ b/components/AuthModal.tsx @@ -9,9 +9,16 @@ type AuthStep = "pick" | "email-input" | "otp-verify"; export function AuthModal({ onClose, onSuccess, + onBeforeOAuth, }: { onClose: () => void; onSuccess: () => void; + // Fires synchronously before the OAuth full-page redirect (signInWithOAuth + // navigates the browser away, unmounting the whole React tree). Hosts that + // need to survive the round-trip (e.g. play page carrying in-memory game + // state) snapshot into sessionStorage here — sessionStorage.setItem is + // synchronous, so it completes before the navigation begins. + onBeforeOAuth?: () => void; }) { const [step, setStep] = useState("pick"); const [email, setEmail] = useState(""); @@ -31,6 +38,9 @@ export function AuthModal({ async (provider: "google" | "github") => { setLoading(true); setError(""); + // Snapshot before navigating away — the redirect below unmounts the app, + // so any host state must be persisted to sessionStorage *now*. + onBeforeOAuth?.(); const supabase = createClient(); const { error: oauthError } = await supabase.auth.signInWithOAuth({ provider, @@ -43,7 +53,7 @@ export function AuthModal({ setLoading(false); } }, - [], + [onBeforeOAuth], ); const handleSendOtp = useCallback(async () => { From 8cdeb1592fa3e908b66e41e2642c40d7ee551339 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 15 Jun 2026 13:38:13 +0800 Subject: [PATCH 2/4] refactor(auth): share OAuth-resume plumbing between home and play pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the page-agnostic resume primitives into lib/authResume.ts: - isAuthed() — single login check (was duplicated in app/page.tsx) - writeResumeSnapshot(key, primary, fallbacks) — quota-safe sessionStorage write with ordered lighter-payload fallbacks (was hand-rolledTry/catch in both pages) - consumeResumeSnapshot(key) — consume-once resume gate that verifies the user is signed in before returning the snapshot, else clears it Both pages now share this plumbing while keeping their own snapshot shapes and restore side effects (home: form fields + start(); play: Session + restorePlayResume + deferred action replay). Unify the persist trigger: home previously snapshotted eagerly inside start() before opening the modal, while play snapshotted in AuthModal.onBeforeOAuth at redirect time. Move home to the same onBeforeOAuth trigger so both pages persist at the single OAuth-redirect instant — the eager-snapshot special case is gone, and OTP (no redirect) keeps its in-place onSuccess resume on both pages. Net: -21 lines. Behavior preserved for OTP; OAuth resume now consistent. --- app/page.tsx | 56 +++++++++++++++------------------- app/play/page.tsx | 77 ++++++++++++++++++++--------------------------- lib/authResume.ts | 72 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 77 deletions(-) create mode 100644 lib/authResume.ts diff --git a/app/page.tsx b/app/page.tsx index 1d42bd0..97db554 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -17,7 +17,7 @@ import { readStoredModelConfig, resolveEngineConfig } from "@/lib/clientModelCon import { STYLE_EXTRACTION_PROMPT } from "@/lib/styleExtraction"; import { STORY_SHARE_STORAGE_KEY, parseStoryShareDoc } from "@/lib/storyShare"; import { AUTH_ENABLED } from "@/lib/supabase/config"; -import { createClient as createSupabaseClient } from "@/lib/supabase/client"; +import { isAuthed, writeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; @@ -854,15 +854,6 @@ function CategorySelect({ const PENDING_START_KEY = "infiplot:pending-start"; const PENDING_PARSE_KEY = "infiplot:pending-parse"; -// True when auth is disabled (self-host with blank Supabase env) or the visitor -// already has a session. Gates the vision call behind login. -async function isAuthed(): Promise { - if (!AUTH_ENABLED) return true; - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - return !!data.user; -} - // Shared by the StyleModal uploader and the post-login resume path: turns a // resized data URL into an English style prompt, via the browser engine when a // BYO model config is present, otherwise the server route. @@ -1399,19 +1390,11 @@ export default function HomePage() { const persistPendingStart = () => { const snap = { prompt, sel, customStyleGuide, customStyleRefImage, playerName }; - try { - sessionStorage.setItem(PENDING_START_KEY, JSON.stringify(snap)); - } catch { - // Quota is usually blown by the data-URL style ref; drop it, keep text. - try { - sessionStorage.setItem( - PENDING_START_KEY, - JSON.stringify({ ...snap, customStyleRefImage: "" }), - ); - } catch { - /* still too big — give up on resume, the form just clears */ - } - } + // Quota fallback: the data-URL style ref (~100KB) is the usual culprit — + // drop it first; text-only form still resumes the start. + writeResumeSnapshot(PENDING_START_KEY, snap, [ + { ...snap, customStyleRefImage: "" }, + ]); }; const resumePendingParse = async () => { @@ -1461,12 +1444,15 @@ export default function HomePage() { // is now signed in, restore and continue; otherwise clear stale snapshots. useEffect(() => { if (!AUTH_ENABLED) return; - const hasPending = - sessionStorage.getItem(PENDING_START_KEY) !== null || - sessionStorage.getItem(PENDING_PARSE_KEY) !== null; - if (!hasPending) return; + const hasStart = sessionStorage.getItem(PENDING_START_KEY) !== null; + const hasParse = sessionStorage.getItem(PENDING_PARSE_KEY) !== null; + if (!hasStart && !hasParse) return; let cancelled = false; void (async () => { + // Gate BOTH snapshots on auth: a stale leftover from an abandoned login + // must not resurrect a half-flow. The parse key stores a raw data URL + // with its own restore path (resumePendingParse), so both are gated + // manually here rather than via consumeResumeSnapshot. if (!(await isAuthed())) { sessionStorage.removeItem(PENDING_START_KEY); sessionStorage.removeItem(PENDING_PARSE_KEY); @@ -1492,10 +1478,11 @@ export default function HomePage() { const start = async () => { if (AUTH_ENABLED) { - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - if (!data.user) { - persistPendingStart(); + if (!(await isAuthed())) { + // Don't snapshot here — persistPendingStart fires via + // AuthModal.onBeforeOAuth at redirect time, so the form is captured + // for BOTH OAuth and (harmlessly) OTP paths at the single source of + // truth. OTP's onSuccess resumes in-place without needing the snapshot. setPendingAction("start"); setAuthModalOpen(true); return; @@ -1992,7 +1979,8 @@ export default function HomePage() { onSuccess={() => { setAuthModalOpen(false); // Email-OTP stays on the page, so resume inline: parse first (it - // reads its own snapshot), then the pending start. + // reads its own snapshot), then the pending start. OTP never + // triggers onBeforeOAuth, so no PENDING_START snapshot was written. void resumePendingParse(); if (pendingAction === "start") { setPendingAction(null); @@ -2004,6 +1992,10 @@ 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} /> )} diff --git a/app/play/page.tsx b/app/play/page.tsx index 7bc87a3..00db2d7 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -52,7 +52,7 @@ import type { } from "@infiplot/types"; import { track } from "@/lib/analytics"; import { AUTH_ENABLED } from "@/lib/supabase/config"; -import { createClient as createSupabaseClient } from "@/lib/supabase/client"; +import { writeResumeSnapshot, consumeResumeSnapshot } from "@/lib/authResume"; import { AuthModal } from "@/components/AuthModal"; import { UserChip } from "@/components/UserChip"; @@ -709,23 +709,15 @@ function PlayInner() { imageOriginalUrl, pendingAction: pendingResumeActionRef.current ?? undefined, }; - const tryWrite = (payload: PlayResumeSnapshot): boolean => { - try { - sessionStorage.setItem(PLAY_RESUME_KEY, JSON.stringify(payload)); - return true; - } catch { - return false; // QuotaExceededError — try a lighter payload below - } - }; - if (tryWrite(snap)) return; - // Drop the heaviest data-URL field (user-uploaded style ref, ~100KB) and - // retry. It only affects the Painter on FUTURE scenes, not the resumed - // scene, so losing it degrades gracefully. Voices are deliberately kept — - // preserving voice continuity matters more than the rare quota miss, and a - // typical session (remote-image URLs + a few ~160KB voice refs) fits well - // under sessionStorage's 5MB cap. If still too big, give up: resume is - // disabled for this trip and the page falls back to normal bootstrap. - tryWrite({ ...snap, session: { ...sess, styleReferenceImage: undefined } }); + // Quota-safe write: the only heavy field is the user-uploaded style ref + // (~100KB data URL), which only affects the Painter on FUTURE scenes, not + // the resumed scene — so stripping it degrades gracefully. Voices are + // deliberately kept (continuity > rare quota miss; a typical session of + // remote-image URLs + a few ~160KB voice refs fits under the 5MB cap). + writeResumeSnapshot(PLAY_RESUME_KEY, snap, [ + // Fallback: drop the style-reference data URL from the session. + { ...snap, session: { ...sess, styleReferenceImage: undefined } }, + ]); }, []); // Restore an in-progress game from a PLAY_RESUME_KEY snapshot after an OAuth @@ -1487,33 +1479,28 @@ function PlayInner() { // login never writes a snapshot — its onSuccess retry keeps state // in-memory. if (AUTH_ENABLED) { - const resumeRaw = sessionStorage.getItem(PLAY_RESUME_KEY); - if (resumeRaw) { - sessionStorage.removeItem(PLAY_RESUME_KEY); - // Let the async resume run; on failure 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. - void (async () => { - try { - const sb = createSupabaseClient(); - const { data } = await sb.auth.getUser(); - if (!data.user) { - // OAuth failed/not signed in → fall back to normal bootstrap. - startedRef.current = false; - setRetryBootstrap((n) => n + 1); - return; - } - await restorePlayResume( - JSON.parse(resumeRaw) as PlayResumeSnapshot, - ); - } catch { - // Corrupt snapshot / network — relinquish and bootstrap normally. - startedRef.current = false; - setRetryBootstrap((n) => n + 1); - } - })(); - return; - } + // 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. + void (async () => { + const snap = await consumeResumeSnapshot( + PLAY_RESUME_KEY, + ); + if (!snap) { + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + return; + } + try { + await restorePlayResume(snap); + } catch { + // Corrupt snapshot / network — relinquish and bootstrap normally. + startedRef.current = false; + setRetryBootstrap((n) => n + 1); + } + })(); + return; } // 三条进入路径: diff --git a/lib/authResume.ts b/lib/authResume.ts new file mode 100644 index 0000000..8def69d --- /dev/null +++ b/lib/authResume.ts @@ -0,0 +1,72 @@ +// Shared primitives for surviving an OAuth full-page round-trip. +// +// Google / GitHub OAuth is a full-page redirect: it unmounts the React tree +// and discards all in-memory state (the server is stateless, so the client +// carries everything). To resume where the user left off after the redirect, +// a page snapshots its domain state into sessionStorage just before navigating +// away, then consumes the snapshot on the next mount — but only if the user is +// now actually signed in. +// +// Email-OTP login never redirects (it resolves in-page), so it bypasses this +// machinery entirely and resumes synchronously via AuthModal.onSuccess. +// +// This module holds the three page-agnostic pieces: the login check, a +// quota-safe sessionStorage write (heavy data-URL fields are stripped on +// QuotaExceededError), and the consume-once resume gate. Each page keeps its +// own snapshot shape and restore side effects — only the plumbing is shared. + +import { AUTH_ENABLED } from "@/lib/supabase/config"; +import { createClient as createSupabaseClient } from "@/lib/supabase/client"; + +// True when auth is disabled (self-host with blank Supabase env) or the visitor +// already has a session. Gates any auth-required action (and the resume path). +export async function isAuthed(): Promise { + if (!AUTH_ENABLED) return true; + const sb = createSupabaseClient(); + const { data } = await sb.auth.getUser(); + return !!data.user; +} + +// Write a resume snapshot to sessionStorage with a quota-safe fallback. +// `fallbacks` is an ordered list of progressively-lighter payloads to try if +// the primary write fails (typically QuotaExceededError from a data-URL image). +// Each fallback drops some non-essential heavy field while keeping the data +// needed to resume. A dropped field only affects *future* generation (e.g. the +// painter on later scenes), never the scene being resumed, so degrading is +// graceful. Returns true if any write succeeded. +export function writeResumeSnapshot( + key: string, + primary: T, + fallbacks: readonly T[] = [], +): boolean { + const tryWrite = (candidate: T): boolean => { + try { + sessionStorage.setItem(key, JSON.stringify(candidate)); + return true; + } catch { + return false; // QuotaExceededError (or disabled storage) + } + }; + if (tryWrite(primary)) return true; + for (const fb of fallbacks) { + if (tryWrite(fb)) return true; + } + return false; +} + +// Consume-once resume gate. Returns the parsed snapshot if one exists at `key` +// AND the user is now signed in (so a stale snapshot from a failed/abandoned +// login doesn't resurrect a half-flow). Always removes the entry — either it's +// consumed here, or it's stale and must not linger. Returns null when there's +// nothing to resume, the user isn't signed in, or the payload is corrupt. +export async function consumeResumeSnapshot(key: string): Promise { + const raw = sessionStorage.getItem(key); + if (!raw) return null; + sessionStorage.removeItem(key); + if (!(await isAuthed())) return null; + try { + return JSON.parse(raw) as T; + } catch { + return null; // corrupt snapshot — ignore + } +} From 6060d76b44b6c5acc90f3d2119a76020c2a44553 Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 15 Jun 2026 13:55:29 +0800 Subject: [PATCH 3/4] 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; From 3a012d46bf3e23345d301cf3b652a71ef382cc8d Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Mon, 15 Jun 2026 14:32:04 +0800 Subject: [PATCH 4/4] fix(auth): harden snapshot paths per PR agent review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address two suggestions from the PR agent review: 1. lib/authResume.ts — catch isAuthed() exceptions in consumeResumeSnapshot. The network/timeout path now returns null (snapshot already removed earlier to prevent the play-page bootstrap's retryBootstrap loop from re-entering this path). Document the intentional removeItem-before-isAuthed ordering. 2. components/AuthModal.tsx — wrap onBeforeOAuth in try-catch so a snapshot failure (e.g. sessionStorage blocked in privacy mode) does not abort the OAuth flow and leave the UI stuck in loading. --- components/AuthModal.tsx | 9 ++++++++- lib/authResume.ts | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/components/AuthModal.tsx b/components/AuthModal.tsx index 46df21b..3ea4c48 100644 --- a/components/AuthModal.tsx +++ b/components/AuthModal.tsx @@ -40,7 +40,14 @@ export function AuthModal({ setError(""); // Snapshot before navigating away — the redirect below unmounts the app, // so any host state must be persisted to sessionStorage *now*. - onBeforeOAuth?.(); + // Non-fatal: if the snapshot fails (e.g. sessionStorage is blocked in + // privacy mode), the OAuth flow still proceeds — the user just won't + // have their in-progress state restored on return. + try { + onBeforeOAuth?.(); + } catch { + /* snapshot failure is non-fatal */ + } const supabase = createClient(); const { error: oauthError } = await supabase.auth.signInWithOAuth({ provider, diff --git a/lib/authResume.ts b/lib/authResume.ts index 8def69d..b2358eb 100644 --- a/lib/authResume.ts +++ b/lib/authResume.ts @@ -59,11 +59,28 @@ export function writeResumeSnapshot( // login doesn't resurrect a half-flow). Always removes the entry — either it's // consumed here, or it's stale and must not linger. Returns null when there's // nothing to resume, the user isn't signed in, or the payload is corrupt. +// +// `removeItem` intentionally runs before `isAuthed()` so that a network error +// during the auth check does not leave a zombie snapshot behind. Without this +// ordering, callers that guard on the snapshot's presence (play-page bootstrap) +// would re-enter this path on every effect cycle, producing an infinite retry +// loop. Dropping the snapshot on a transient network glitch is an acceptable +// trade-off — the worst case is the user lands on the first scene instead of +// resuming mid-story, which is the same experience as before this feature. export async function consumeResumeSnapshot(key: string): Promise { const raw = sessionStorage.getItem(key); if (!raw) return null; sessionStorage.removeItem(key); - if (!(await isAuthed())) return null; + let authed: boolean; + try { + authed = await isAuthed(); + } catch { + // Network / unexpected error during auth check. Snapshot already removed + // (prevents the caller's retry loop); return null so callers fall back to + // their default path (normal bootstrap). + return null; + } + if (!authed) return null; try { return JSON.parse(raw) as T; } catch {