refactor(auth): share OAuth-resume plumbing between home and play pages

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<T>(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.
This commit is contained in:
yuanzonghao
2026-06-15 13:38:13 +08:00
parent 99ad8d111e
commit 8cdeb1592f
3 changed files with 128 additions and 77 deletions
+72
View File
@@ -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<boolean> {
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<T>(
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<T>(key: string): Promise<T | null> {
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
}
}