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
+17 -14
View File
@@ -9,11 +9,13 @@ function createTestAuthMachine(
overrides: Partial<{
checkAuthStatus: LoginStatus;
guestLogin: LoginStatus;
guestLoginSpy: () => void;
emailLogin: LoginStatus;
logoutSpy: () => void;
}> = {},
) {
const logoutSpy = overrides.logoutSpy ?? vi.fn<() => void>();
const guestLoginSpy = overrides.guestLoginSpy ?? vi.fn<() => void>();
return authMachine.provide({
actors: {
@@ -21,7 +23,10 @@ function createTestAuthMachine(
async () => overrides.checkAuthStatus ?? "notLoggedIn",
),
guestLogin: fromPromise<LoginStatus, void>(
async () => overrides.guestLogin ?? "guest",
async () => {
guestLoginSpy();
return overrides.guestLogin ?? "guest";
},
),
emailLogin: fromPromise<
LoginStatus,
@@ -72,9 +77,10 @@ describe("authMachine", () => {
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(
createTestAuthMachine({ checkAuthStatus: "facebook" }),
createTestAuthMachine({ checkAuthStatus: "facebook", guestLoginSpy }),
).start();
actor.send({ type: "AuthInit" });
@@ -83,26 +89,27 @@ describe("authMachine", () => {
const context = actor.getSnapshot().context;
expect(context.loginStatus).toBe("facebook");
expect(context.hasInitialized).toBe(true);
expect(context.pendingRedirect).toBe(false);
expect(guestLoginSpy).not.toHaveBeenCalled();
actor.stop();
});
it("guest login marks initialized but does not set pending redirect", async () => {
const actor = createActor(createTestAuthMachine()).start();
it("automatically guest logs in when initialization finds no login state", async () => {
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"));
const context = actor.getSnapshot().context;
expect(context.loginStatus).toBe("guest");
expect(context.hasInitialized).toBe(true);
expect(context.pendingRedirect).toBe(false);
expect(guestLoginSpy).toHaveBeenCalledOnce();
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();
actor.send({
@@ -113,10 +120,7 @@ describe("authMachine", () => {
await waitFor(actor, (snapshot) => snapshot.matches("idle"));
expect(actor.getSnapshot().context.loginStatus).toBe("email");
expect(actor.getSnapshot().context.pendingRedirect).toBe(true);
actor.send({ type: "AuthClearPendingRedirect" });
expect(actor.getSnapshot().context.pendingRedirect).toBe(false);
expect(actor.getSnapshot().context.hasInitialized).toBe(true);
actor.stop();
});
@@ -138,7 +142,6 @@ describe("authMachine", () => {
const context = actor.getSnapshot().context;
expect(logoutSpy).toHaveBeenCalledOnce();
expect(context.loginStatus).toBe("notLoggedIn");
expect(context.pendingRedirect).toBe(false);
expect(context.hasInitialized).toBe(true);
actor.stop();
+3 -3
View File
@@ -193,13 +193,13 @@ async function persistFacebookProfile(accessToken: string): Promise<void> {
}
}
// ========== 显式游客登录 actoropt-in ==========
// 由 splash UI 派发 `AuthGuestLoginSubmitted` 触发,不自动跑
// ========== 自动游客登录 actor ==========
// AuthInit 检查不到任何本地登录态时触发,用于自动补齐游客会话
export const guestLoginActor = fromPromise<LoginStatus, void>(async () => {
log.debug("[auth-machine] guestLoginActor ENTRY");
// 1. 先查本地 guestToken —— 已有就直接进 idle(带 pendingRedirect
// 1. 先查本地 guestToken —— 已有就直接进入游客态
const hasTokenR = await AuthStorage.getInstance().hasGuestToken();
log.debug("[auth-machine] guestLoginActor hasGuestToken", {
success: hasTokenR.success,
+1 -8
View File
@@ -36,12 +36,6 @@ interface AuthState {
loginStatus: MachineContext["loginStatus"];
/** 启动时的本地登录态恢复是否已经完成 */
hasInitialized: MachineContext["hasInitialized"];
/**
* 一次性的"刚按了"信号 —— Skip / Facebook / 邮箱登录按钮派完业务事件后置 true,
* splash-screen / auth-screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"`
* 触发跳转 + 调 `AuthClearPendingRedirect` 置 false。
*/
pendingRedirect: boolean;
}
const AuthStateCtx = createContext<AuthState | null>(null);
@@ -64,7 +58,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
username: state.context.username,
confirmPassword: state.context.confirmPassword,
// isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/
// OAuth 回调后端 sync / 显式游客登录
// OAuth 回调后端 sync / 自动游客登录
isLoading:
state.matches("loadingEmailLogin") ||
state.matches("loadingEmailRegister") ||
@@ -77,7 +71,6 @@ export function AuthProvider({ children }: AuthProviderProps) {
errorMessage: state.context.errorMessage,
loginStatus: state.context.loginStatus,
hasInitialized: state.context.hasInitialized,
pendingRedirect: state.context.pendingRedirect,
}),
[state],
);
+1 -8
View File
@@ -14,8 +14,6 @@ export type AuthEvent =
| { type: "AuthLogoutSubmitted" }
/** App 启动时一次性派发 —— 读 storage 把 loginStatus 同步到状态机 */
| { type: "AuthInit" }
/** 显式游客登录 —— splash 上点"游客模式"按钮触发(不自动) */
| { type: "AuthGuestLoginSubmitted" }
// 业务事件(提交)
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
| {
@@ -32,9 +30,4 @@ export type AuthEvent =
// OAuth 回调后:把 OAuth provider token 转交后端换业务 token
// 由 <OAuthSessionSync /> 监听 useSession() 派发
| { type: "AuthGoogleSyncSubmitted"; idToken: string }
| { type: "AuthFacebookSyncSubmitted"; accessToken: string }
// ──────────────────────────────────────────────────────────────────────
// splash / auth screen 跳完 /chat 后清"刚按了"信号(一次性):
// `pendingRedirect` 在 login onDone action 里自动置 true(不是按钮派的)
// —— 因此只需要 `AuthClearPendingRedirect` 一个事件
| { type: "AuthClearPendingRedirect" };
| { type: "AuthFacebookSyncSubmitted"; accessToken: string };
+22 -26
View File
@@ -32,11 +32,7 @@ const toAuthErrorMessage = (error: unknown): string =>
//
// 设计:所有登录/同步流跑完都回 `idle`(带正确 `loginStatus`)。
//
// `pendingRedirect: boolean`"用户显式触发了登录"信号)——
// 在登录成功的 onDone 里自动置 true
// splash / auth screen 看到 `pendingRedirect && loginStatus !== "notLoggedIn"` 跳 /chat
// 跳完派 `AuthClearPendingRedirect` 置 false
// `initializing` 不设 flag —— 启动 init 是被动读 token,不算"用户意图"。
// 未找到任何登录态时,初始化流程会自动进入 guest login,补齐游客会话。
// ============================================================
export const authMachine = setup({
types: {
@@ -86,7 +82,6 @@ export const authMachine = setup({
// 启动一次性 init:从 storage 同步 loginStatus 到状态机 —— 由 <AuthStatusChecker /> 派发
AuthInit: "initializing",
AuthGuestLoginSubmitted: "loadingGuestLogin",
AuthEmailLoginSubmitted: "loadingEmailLogin",
AuthEmailRegisterSubmitted: "loadingEmailRegister",
AuthGoogleLoginSubmitted: "loadingOAuth",
@@ -100,10 +95,6 @@ export const authMachine = setup({
// OAuth 回调后 sync 入口 —— 由 <OAuthSessionSync /> 派发
AuthGoogleSyncSubmitted: "syncingGoogleBackend",
AuthFacebookSyncSubmitted: "syncingFacebookBackend",
// splash / auth screen 跳完 /chat 后清flag
AuthClearPendingRedirect: {
actions: assign({ pendingRedirect: false }),
},
},
},
@@ -111,15 +102,26 @@ export const authMachine = setup({
entry: assign({ errorMessage: null }),
invoke: {
src: "checkAuthStatus",
onDone: {
target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地)
actions: assign({
loginStatus: ({ event }) => event.output,
hasInitialized: true,
// 不置 pendingRedirect —— 状态查询是被动读 token,不算"用户意图"
errorMessage: null,
}),
},
onDone: [
{
guard: ({ event }) => event.output === "notLoggedIn",
target: "loadingGuestLogin",
actions: assign({
loginStatus: "notLoggedIn",
// 自动游客登录仍属于初始化流程,完成前不标记 initialized。
hasInitialized: false,
errorMessage: null,
}),
},
{
target: "idle", // ← init 完回 idle(从 storage 拿到 loginStatus 后落地)
actions: assign({
loginStatus: ({ event }) => event.output,
hasInitialized: true,
errorMessage: null,
}),
},
],
onError: {
target: "idle",
actions: assign({
@@ -139,8 +141,6 @@ export const authMachine = setup({
// 内联 assign —— XState v5 type inference 保留 loadingGuestLogin state 类型
actions: assign({
loginStatus: ({ event }) => event.output,
// Skip 点击后已在 splash 里立即跳 /chatguest login 不再依赖 pendingRedirect。
pendingRedirect: false,
errorMessage: null,
hasInitialized: true,
}),
@@ -168,7 +168,6 @@ export const authMachine = setup({
target: "idle",
actions: assign({
loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← 邮箱登录成功 → 跳
errorMessage: null,
hasInitialized: true,
}),
@@ -201,7 +200,6 @@ export const authMachine = setup({
target: "idle",
actions: assign({
loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← 注册成功 → 跳
errorMessage: null,
hasInitialized: true,
}),
@@ -238,7 +236,7 @@ export const authMachine = setup({
},
loggingOut: {
entry: assign({ errorMessage: null, pendingRedirect: false }),
entry: assign({ errorMessage: null }),
invoke: {
src: "logout",
onDone: {
@@ -271,7 +269,6 @@ export const authMachine = setup({
target: "idle",
actions: assign({
loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← OAuth sync 成功 → 跳
errorMessage: null,
hasInitialized: true,
}),
@@ -298,7 +295,6 @@ export const authMachine = setup({
target: "idle",
actions: assign({
loginStatus: ({ event }) => event.output,
pendingRedirect: true, // ← OAuth sync 成功 → 跳
errorMessage: null,
hasInitialized: true,
}),
-10
View File
@@ -17,15 +17,6 @@ export interface AuthState {
loginStatus: LoginStatus;
/** 启动时的本地登录态恢复是否已经完成 */
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 = {
@@ -38,5 +29,4 @@ export const initialState: AuthState = {
errorMessage: null,
loginStatus: "notLoggedIn",
hasInitialized: false,
pendingRedirect: false,
};