refactor(auth): simplify splash guest entry

This commit is contained in:
2026-07-03 15:57:01 +08:00
parent 79324defcf
commit b22f23bcc4
10 changed files with 72 additions and 205 deletions
+9 -14
View File
@@ -7,7 +7,8 @@
import { useEffect, useSyncExternalStore } from "react"; import { useEffect, useSyncExternalStore } from "react";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { isAuthenticatedUser } from "@/router/navigation-resolver";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
@@ -19,7 +20,6 @@ const log = new Logger("AppAuthAuthScreen");
export function AuthScreen() { export function AuthScreen() {
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch();
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const redirectTo = useSyncExternalStore( const redirectTo = useSyncExternalStore(
subscribeLocationSnapshot, subscribeLocationSnapshot,
@@ -28,28 +28,23 @@ export function AuthScreen() {
); );
const safeRedirectTo = getSafeRedirectPath(redirectTo) ?? ROUTES.chat; const safeRedirectTo = getSafeRedirectPath(redirectTo) ?? ROUTES.chat;
// 邮箱登录成功 → 跳转;User hydrate 由根级 <UserAuthSync /> 监听 loginStatus 变化处理。 // 真实用户登录成功 → 跳转;User hydrate 由根级 <UserAuthSync /> 监听 loginStatus 变化处理。
// 用 pendingRedirect flag(不是 useRef transition detection)—— // 游客态不触发跳转,避免自动游客登录打断 auth 页面。
// re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。
useEffect(() => { useEffect(() => {
log.debug("[auth-screen] useEffect (loginStatus + pendingRedirect)", { const shouldRedirect = isAuthenticatedUser(state.loginStatus);
pending: state.pendingRedirect, log.debug("[auth-screen] useEffect (loginStatus)", {
loginStatus: state.loginStatus, loginStatus: state.loginStatus,
willRedirect: willRedirect: shouldRedirect,
state.pendingRedirect && state.loginStatus !== "notLoggedIn",
}); });
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") { if (shouldRedirect) {
log.debug("[auth-screen] useEffect → navigator.replace [via pendingRedirect flag]", { log.debug("[auth-screen] useEffect → navigator.replace", {
redirectTo: safeRedirectTo, redirectTo: safeRedirectTo,
}); });
authDispatch({ type: "AuthClearPendingRedirect" });
navigator.replace(safeRedirectTo); navigator.replace(safeRedirectTo);
} }
}, [ }, [
state.loginStatus, state.loginStatus,
state.pendingRedirect,
authDispatch,
navigator, navigator,
safeRedirectTo, safeRedirectTo,
]); ]);
@@ -5,31 +5,13 @@
justify-content: center; justify-content: center;
gap: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px)); gap: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px));
width: 100%; width: 100%;
padding: 0 clamp(var(--spacing-sm, 8px), 2.963vw, var(--spacing-lg, 16px));
z-index: 2; z-index: 2;
} }
.skip {
font-size: var(--responsive-section-title, var(--font-size-xxl));
font-weight: 700;
font-style: italic;
color: #ffffff;
background: transparent;
border: 0;
cursor: pointer;
padding: 0;
}
.skip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.separator {
width: clamp(var(--spacing-md, 12px), 4.815vw, var(--spacing-26, 26px));
height: 1px;
}
.facebookButton { .facebookButton {
width: 100%;
max-width: 480px;
min-height: var(--responsive-control-height, 48px); min-height: var(--responsive-control-height, 48px);
padding: 0 clamp(var(--spacing-lg, 16px), 5.556vw, 30px); padding: 0 clamp(var(--spacing-lg, 16px), 5.556vw, 30px);
border: 0; border: 0;
@@ -47,7 +29,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex: 0 0 auto; flex: 1 1 auto;
box-shadow: 0 0 10px rgba(248, 89, 168, 0.3); box-shadow: 0 0 10px rgba(248, 89, 168, 0.3);
} }
+5 -30
View File
@@ -2,55 +2,30 @@
/** /**
* Splash 底部按钮组 * Splash 底部按钮组
*/ */
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { LoadingIndicator } from "@/app/_components/core/loading-indicator"; import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
import styles from "./splash-button.module.css"; import styles from "./splash-button.module.css";
export interface SplashButtonProps { export interface SplashButtonProps {
onSkip: () => void; onStartChat: () => void;
} }
export function SplashButton({ onSkip }: SplashButtonProps) { export function SplashButton({ onStartChat }: SplashButtonProps) {
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch();
const isLoading = state.isLoading; const isLoading = state.isLoading;
const handleFacebookLogin = () => {
authDispatch({
type: "AuthFacebookLoginSubmitted",
provider: "facebook",
});
};
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
{/* Skip 按钮 = 显式游客登录入口(派发事件 + 立即跳 /chat) */}
<button <button
type="button" type="button"
onClick={onSkip} onClick={onStartChat}
disabled={isLoading}
className={styles.skip}
>
{isLoading ? (
<LoadingIndicator color="#ffffff" />
) : (
"Skip"
)}
</button>
<div className={styles.separator} aria-hidden="true" />
{/* 社交登录按钮(NextAuth Facebook OAuth */}
<button
type="button"
onClick={handleFacebookLogin}
disabled={isLoading} disabled={isLoading}
className={styles.facebookButton} className={styles.facebookButton}
> >
{isLoading ? ( {isLoading ? (
<LoadingIndicator color="#ffffff" /> <LoadingIndicator color="#ffffff" />
) : ( ) : (
<span className={styles.facebookLabel}>Login with Facebook</span> <span className={styles.facebookLabel}>Start Chatting</span>
)} )}
</button> </button>
</div> </div>
+10 -70
View File
@@ -3,10 +3,9 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { hasBusinessLoginToken } from "@/lib/auth/auth_session";
import { useAppNavigator } from "@/router/use-app-navigator"; import { useAppNavigator } from "@/router/use-app-navigator";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { pwaUtil, Logger } from "@/utils"; import { pwaUtil } from "@/utils";
import { import {
SplashBackground, SplashBackground,
@@ -16,7 +15,7 @@ import {
} from "./components"; } from "./components";
import styles from "./components/splash-screen.module.css"; import styles from "./components/splash-screen.module.css";
const log = new Logger("AppSplashSplashScreen"); const AUTO_OPEN_CHAT_DELAY_MS = 1800;
// 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。 // 尽量早地缓存 beforeinstallprompt,避免用户停留 splash 时浏览器事件已触发并丢失。
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -26,10 +25,8 @@ if (typeof window !== "undefined") {
export function SplashScreen() { export function SplashScreen() {
const navigator = useAppNavigator(); const navigator = useAppNavigator();
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch();
const handleSkip = () => { const handleStartChat = () => {
authDispatch({ type: "AuthGuestLoginSubmitted" });
navigator.openChat({ replace: true }); navigator.openChat({ replace: true });
}; };
@@ -37,50 +34,13 @@ export function SplashScreen() {
pwaUtil.prepareInstallPrompt(); pwaUtil.prepareInstallPrompt();
}, []); }, []);
// ─────────────────────────────────────────────────────────────
// useEffect ① 启动时检查 localStorage 里的 loginToken
// - loginTokenOAuth/email sync 写下的)→ 视为"已登录"跳 /chat
// - 不查 guestToken —— 游客用户停留 splash(按你要求)
// - 都没有 → 留 splash
// proxy 读不到 localStorageEdge runtime),所以这条 fallback 必须在 Client 侧。
// ─────────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
let cancelled = false; const timer = window.setTimeout(() => {
void (async () => {
if (!cancelled && (await hasBusinessLoginToken())) {
navigator.openChat({ replace: true });
}
})();
return () => {
cancelled = true;
};
}, [navigator]);
// ─────────────────────────────────────────────────────────────
// useEffect ② 按钮刚派了事件(pendingRedirect=true +
// loginStatus 是非 notLoggedIn → 跳 /chat
//
// 关键:`pendingRedirect` 是 auth context 里的持久状态(不是 ref)——
// re-mount 时不重置,区分"刚按了"和"历史登录"准确无误。
// 跳完调 `AuthClearPendingRedirect` 置 false(一次性信号)。
// ─────────────────────────────────────────────────────────────
useEffect(() => {
// 调试日志(保留,不删 —— 用于排查"re-mount 误跳 /chat"问题)
log.debug("[splash] useEffect ② (loginStatus + pendingRedirect)", {
pending: state.pendingRedirect,
loginStatus: state.loginStatus,
willRedirect:
state.pendingRedirect && state.loginStatus !== "notLoggedIn",
});
if (state.pendingRedirect && state.loginStatus !== "notLoggedIn") {
log.debug(
"[splash] useEffect ② → navigator.openChat [via pendingRedirect flag]",
);
authDispatch({ type: "AuthClearPendingRedirect" });
navigator.openChat({ replace: true }); navigator.openChat({ replace: true });
} }, AUTO_OPEN_CHAT_DELAY_MS);
}, [state.loginStatus, state.pendingRedirect, authDispatch, navigator]);
return () => window.clearTimeout(timer);
}, [navigator]);
return ( return (
<MobileShell background="var(--color-sidebar-background)"> <MobileShell background="var(--color-sidebar-background)">
@@ -93,28 +53,8 @@ export function SplashScreen() {
<div className={styles.spacer} /> <div className={styles.spacer} />
<SplashContent /> <SplashContent />
<div className={styles.buttonArea}> <div className={styles.buttonArea}>
<SplashButton onSkip={handleSkip} /> <SplashButton onStartChat={handleStartChat} />
</div> </div>
{/*
* 登录错误提示 —— auth state machine 的 onError 会设 errorMessage
* (如 Facebook 同步后端失败 / 后端返回数据 schema 不匹配等)。
* 之前没显示,导致用户卡在 splash 看不到原因。
*/}
{state.errorMessage ? (
<p
role="alert"
style={{
color: "#c0392b",
fontSize: "0.875rem",
marginTop: "1rem",
textAlign: "center",
maxWidth: "320px",
lineHeight: 1.4,
}}
>
{state.errorMessage}
</p>
) : null}
<p className={styles.bottom}> <p className={styles.bottom}>
Elio Silvestri, Your exclusive AI boyfriend Elio Silvestri, Your exclusive AI boyfriend
<br /> <br />
+17 -14
View File
@@ -9,11 +9,13 @@ function createTestAuthMachine(
overrides: Partial<{ overrides: Partial<{
checkAuthStatus: LoginStatus; checkAuthStatus: LoginStatus;
guestLogin: LoginStatus; guestLogin: LoginStatus;
guestLoginSpy: () => void;
emailLogin: LoginStatus; emailLogin: LoginStatus;
logoutSpy: () => void; logoutSpy: () => void;
}> = {}, }> = {},
) { ) {
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>(); const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>();
return authMachine.provide({ return authMachine.provide({
actors: { actors: {
@@ -21,7 +23,10 @@ function createTestAuthMachine(
async () => overrides.checkAuthStatus ?? "notLoggedIn", async () => overrides.checkAuthStatus ?? "notLoggedIn",
), ),
guestLogin: fromPromise<LoginStatus, void>( guestLogin: fromPromise<LoginStatus, void>(
async () => overrides.guestLogin ?? "guest", async () => {
guestLoginSpy();
return overrides.guestLogin ?? "guest";
},
), ),
emailLogin: fromPromise< emailLogin: fromPromise<
LoginStatus, LoginStatus,
@@ -72,9 +77,10 @@ describe("authMachine", () => {
actor.stop(); actor.stop();
}); });
it("initializes from storage without triggering a pending redirect", async () => { it("initializes from storage without triggering guest login", async () => {
const guestLoginSpy = vi.fn<() => void>();
const actor = createActor( const actor = createActor(
createTestAuthMachine({ checkAuthStatus: "facebook" }), createTestAuthMachine({ checkAuthStatus: "facebook", guestLoginSpy }),
).start(); ).start();
actor.send({ type: "AuthInit" }); actor.send({ type: "AuthInit" });
@@ -83,26 +89,27 @@ describe("authMachine", () => {
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.loginStatus).toBe("facebook"); expect(context.loginStatus).toBe("facebook");
expect(context.hasInitialized).toBe(true); expect(context.hasInitialized).toBe(true);
expect(context.pendingRedirect).toBe(false); expect(guestLoginSpy).not.toHaveBeenCalled();
actor.stop(); actor.stop();
}); });
it("guest login marks initialized but does not set pending redirect", async () => { it("automatically guest logs in when initialization finds no login state", async () => {
const actor = createActor(createTestAuthMachine()).start(); const guestLoginSpy = vi.fn<() => void>();
const actor = createActor(createTestAuthMachine({ guestLoginSpy })).start();
actor.send({ type: "AuthGuestLoginSubmitted" }); actor.send({ type: "AuthInit" });
await waitFor(actor, (snapshot) => snapshot.matches("idle")); await waitFor(actor, (snapshot) => snapshot.matches("idle"));
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(context.loginStatus).toBe("guest"); expect(context.loginStatus).toBe("guest");
expect(context.hasInitialized).toBe(true); expect(context.hasInitialized).toBe(true);
expect(context.pendingRedirect).toBe(false); expect(guestLoginSpy).toHaveBeenCalledOnce();
actor.stop(); actor.stop();
}); });
it("email login sets pending redirect until it is cleared", async () => { it("email login updates login status", async () => {
const actor = createActor(createTestAuthMachine()).start(); const actor = createActor(createTestAuthMachine()).start();
actor.send({ actor.send({
@@ -113,10 +120,7 @@ describe("authMachine", () => {
await waitFor(actor, (snapshot) => snapshot.matches("idle")); await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.loginStatus).toBe("email"); expect(actor.getSnapshot().context.loginStatus).toBe("email");
expect(actor.getSnapshot().context.pendingRedirect).toBe(true); expect(actor.getSnapshot().context.hasInitialized).toBe(true);
actor.send({ type: "AuthClearPendingRedirect" });
expect(actor.getSnapshot().context.pendingRedirect).toBe(false);
actor.stop(); actor.stop();
}); });
@@ -138,7 +142,6 @@ describe("authMachine", () => {
const context = actor.getSnapshot().context; const context = actor.getSnapshot().context;
expect(logoutSpy).toHaveBeenCalledOnce(); expect(logoutSpy).toHaveBeenCalledOnce();
expect(context.loginStatus).toBe("notLoggedIn"); expect(context.loginStatus).toBe("notLoggedIn");
expect(context.pendingRedirect).toBe(false);
expect(context.hasInitialized).toBe(true); expect(context.hasInitialized).toBe(true);
actor.stop(); actor.stop();
+3 -3
View File
@@ -193,13 +193,13 @@ async function persistFacebookProfile(accessToken: string): Promise<void> {
} }
} }
// ========== 显式游客登录 actoropt-in ========== // ========== 自动游客登录 actor ==========
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑 // AuthInit 检查不到任何本地登录态时触发,用于自动补齐游客会话
export const guestLoginActor = fromPromise<LoginStatus, void>(async () => { export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
log.debug("[auth-machine] guestLoginActor ENTRY"); log.debug("[auth-machine] guestLoginActor ENTRY");
// 1. 先查本地 guestToken —— 已有就直接进 idle(带 pendingRedirect // 1. 先查本地 guestToken —— 已有就直接进入游客态
const hasTokenR = await AuthStorage.getInstance().hasGuestToken(); const hasTokenR = await AuthStorage.getInstance().hasGuestToken();
log.debug("[auth-machine] guestLoginActor hasGuestToken", { log.debug("[auth-machine] guestLoginActor hasGuestToken", {
success: hasTokenR.success, success: hasTokenR.success,
+1 -8
View File
@@ -36,12 +36,6 @@ interface AuthState {
loginStatus: MachineContext["loginStatus"]; loginStatus: MachineContext["loginStatus"];
/** 启动时的本地登录态恢复是否已经完成 */ /** 启动时的本地登录态恢复是否已经完成 */
hasInitialized: MachineContext["hasInitialized"]; hasInitialized: MachineContext["hasInitialized"];
/**
* 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮派完业务事件后置 true,
* splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"`
* 触发跳转 + 调 `AuthClearPendingRedirect` 置 false。
*/
pendingRedirect: boolean;
} }
const AuthStateCtx = createContext<AuthState | null>(null); const AuthStateCtx = createContext<AuthState | null>(null);
@@ -64,7 +58,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
username: state.context.username, username: state.context.username,
confirmPassword: state.context.confirmPassword, confirmPassword: state.context.confirmPassword,
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/ // isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/
// OAuth 回调后端 sync / 显式游客登录 // OAuth 回调后端 sync / 自动游客登录
isLoading: isLoading:
state.matches("loadingEmailLogin") || state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister") || state.matches("loadingEmailRegister") ||
@@ -77,7 +71,6 @@ export function AuthProvider({ children }: AuthProviderProps) {
errorMessage: state.context.errorMessage, errorMessage: state.context.errorMessage,
loginStatus: state.context.loginStatus, loginStatus: state.context.loginStatus,
hasInitialized: state.context.hasInitialized, hasInitialized: state.context.hasInitialized,
pendingRedirect: state.context.pendingRedirect,
}), }),
[state], [state],
); );
+1 -8
View File
@@ -14,8 +14,6 @@ export type AuthEvent =
| { type: "AuthLogoutSubmitted" } | { type: "AuthLogoutSubmitted" }
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */ /** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
| { type: "AuthInit" } | { type: "AuthInit" }
/** 显式游客登录 —— splash 上点"游客模式"按钮触发(不自动) */
| { type: "AuthGuestLoginSubmitted" }
// 业务事件(提交) // 业务事件(提交)
| { type: "AuthEmailLoginSubmitted"; email: string; password: string } | { type: "AuthEmailLoginSubmitted"; email: string; password: string }
| { | {
@@ -32,9 +30,4 @@ export type AuthEvent =
// OAuth 回调后:把 OAuth provider token 转交后端换业务 token // OAuth 回调后:把 OAuth provider token 转交后端换业务 token
// 由 <OAuthSessionSync /> 监听 useSession() 派发 // 由 <OAuthSessionSync /> 监听 useSession() 派发
| { type: "AuthGoogleSyncSubmitted"; idToken: string } | { type: "AuthGoogleSyncSubmitted"; idToken: string }
| { type: "AuthFacebookSyncSubmitted"; accessToken: string } | { type: "AuthFacebookSyncSubmitted"; accessToken: string };
// ──────────────────────────────────────────────────────────────────────
// splash / auth screen 跳完 /chat 后清"刚按了"信号(一次性):
// `pendingRedirect` 在 login onDone action 里自动置 true(不是按钮派的)
// —— 因此只需要 `AuthClearPendingRedirect` 一个事件
| { type: "AuthClearPendingRedirect" };
+22 -26
View File
@@ -32,11 +32,7 @@ const toAuthErrorMessage = (error: unknown): string =>
// //
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。 // 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
// //
// `pendingRedirect: boolean`"用户显式触发了登录"信号)—— // 未找到任何登录态时,初始化流程会自动进入 guest login,补齐游客会话。
// 在登录成功的 onDone 里自动置 true
// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat
// 跳完派 `AuthClearPendingRedirect` 置 false
// `initializing` 不设 flag —— 启动 init 是被动读 token,不算"用户意图"。
// ============================================================ // ============================================================
export const authMachine = setup({ export const authMachine = setup({
types: { types: {
@@ -86,7 +82,6 @@ export const authMachine = setup({
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发 // 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
AuthInit: "initializing", AuthInit: "initializing",
AuthGuestLoginSubmitted: "loadingGuestLogin",
AuthEmailLoginSubmitted: "loadingEmailLogin", AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister", AuthEmailRegisterSubmitted: "loadingEmailRegister",
AuthGoogleLoginSubmitted: "loadingOAuth", AuthGoogleLoginSubmitted: "loadingOAuth",
@@ -100,10 +95,6 @@ export const authMachine = setup({
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发 // OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
AuthGoogleSyncSubmitted: "syncingGoogleBackend", AuthGoogleSyncSubmitted: "syncingGoogleBackend",
AuthFacebookSyncSubmitted: "syncingFacebookBackend", AuthFacebookSyncSubmitted: "syncingFacebookBackend",
// splash / auth screen 跳完 /chat 后清flag
AuthClearPendingRedirect: {
actions: assign({ pendingRedirect: false }),
},
}, },
}, },
@@ -111,15 +102,26 @@ export const authMachine = setup({
entry: assign({ errorMessage: null }), entry: assign({ errorMessage: null }),
invoke: { invoke: {
src: "checkAuthStatus", src: "checkAuthStatus",
onDone: { onDone: [
target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地) {
actions: assign({ guard: ({ event }) => event.output === "notLoggedIn",
loginStatus: ({ event }) => event.output, target: "loadingGuestLogin",
hasInitialized: true, actions: assign({
// 不置 pendingRedirect —— 状态查询是被动读 token,不算"用户意图" loginStatus: "notLoggedIn",
errorMessage: null, // 自动游客登录仍属于初始化流程,完成前不标记 initialized。
}), hasInitialized: false,
}, errorMessage: null,
}),
},
{
target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地)
actions: assign({
loginStatus: ({ event }) => event.output,
hasInitialized: true,
errorMessage: null,
}),
},
],
onError: { onError: {
target: "idle", target: "idle",
actions: assign({ actions: assign({
@@ -139,8 +141,6 @@ export const authMachine = setup({
// 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型 // 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
// Skip 点击后已在 splash 里立即跳 /chatguest login 不再依赖 pendingRedirect。
pendingRedirect: false,
errorMessage: null, errorMessage: null,
hasInitialized: true, hasInitialized: true,
}), }),
@@ -168,7 +168,6 @@ export const authMachine = setup({
target: "idle", target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← 邮箱登录成功 → 跳
errorMessage: null, errorMessage: null,
hasInitialized: true, hasInitialized: true,
}), }),
@@ -201,7 +200,6 @@ export const authMachine = setup({
target: "idle", target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← 注册成功 → 跳
errorMessage: null, errorMessage: null,
hasInitialized: true, hasInitialized: true,
}), }),
@@ -238,7 +236,7 @@ export const authMachine = setup({
}, },
loggingOut: { loggingOut: {
entry: assign({ errorMessage: null, pendingRedirect: false }), entry: assign({ errorMessage: null }),
invoke: { invoke: {
src: "logout", src: "logout",
onDone: { onDone: {
@@ -271,7 +269,6 @@ export const authMachine = setup({
target: "idle", target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← OAuth sync 成功 → 跳
errorMessage: null, errorMessage: null,
hasInitialized: true, hasInitialized: true,
}), }),
@@ -298,7 +295,6 @@ export const authMachine = setup({
target: "idle", target: "idle",
actions: assign({ actions: assign({
loginStatus: ({ event }) => event.output, loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← OAuth sync 成功 → 跳
errorMessage: null, errorMessage: null,
hasInitialized: true, hasInitialized: true,
}), }),
-10
View File
@@ -17,15 +17,6 @@ export interface AuthState {
loginStatus: LoginStatus; loginStatus: LoginStatus;
/** 启动时的本地登录态恢复是否已经完成 */ /** 启动时的本地登录态恢复是否已经完成 */
hasInitialized: boolean; hasInitialized: boolean;
/**
* 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮刚派事件 → 置 true
* → splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 触发跳转
* → 跳完置 false
*
* 历史:之前用 useRef 做 transition detection,但 re-mount 时 ref 重置,bug 复发。
* 现在 auth state 里持久(machine 不重置)—— re-mount 时也能区分"刚按"和"历史"。
*/
pendingRedirect: boolean;
} }
export const initialState: AuthState = { export const initialState: AuthState = {
@@ -38,5 +29,4 @@ export const initialState: AuthState = {
errorMessage: null, errorMessage: null,
loginStatus: "notLoggedIn", loginStatus: "notLoggedIn",
hasInitialized: false, hasInitialized: false,
pendingRedirect: false,
}; };