refactor(auth): simplify splash guest entry
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -193,13 +193,13 @@ async function persistFacebookProfile(accessToken: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 显式游客登录 actor(opt-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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 里立即跳 /chat;guest 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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user