From 37ae152abbb3f633f718ea80fa22149fd0b33bb3 Mon Sep 17 00:00:00 2001 From: chenhang Date: Mon, 13 Jul 2026 19:07:53 +0800 Subject: [PATCH] refactor: scope providers by route and use XState selectors --- .../chat/components/chat-unlock-dialogs.tsx | 17 +++- src/app/chat/components/message-bubble.tsx | 4 +- .../hooks/use-chat-message-limit-banner.ts | 6 +- .../hooks/use-chat-unlock-navigation-flow.ts | 20 +++- .../hooks/use-first-recharge-offer-banner.ts | 27 ++++-- src/app/chat/layout.tsx | 7 ++ src/app/private-room/layout.tsx | 11 +++ src/app/subscription/layout.tsx | 11 +++ src/app/tip/layout.tsx | 7 ++ src/providers/chat-route-providers.tsx | 24 +++++ src/providers/payment-route-provider.tsx | 15 +++ src/providers/private-room-route-provider.tsx | 13 +++ src/providers/root-providers.tsx | 35 +------ src/router/app-navigation-guard.tsx | 15 ++- src/router/use-app-navigator.ts | 20 ++-- .../auth/__tests__/auth-context.test.tsx | 66 +++++++++++++ src/stores/auth/auth-context.tsx | 94 ++++++++---------- src/stores/chat/chat-context.tsx | 92 +++++++++--------- src/stores/payment/payment-context.tsx | 95 ++++++++----------- .../private-room/private-room-context.tsx | 93 ++++++++---------- src/stores/sync/chat-auth-sync.tsx | 15 ++- src/stores/sync/payment-success-sync.tsx | 67 +++++++++---- src/stores/sync/user-auth-sync.tsx | 15 ++- src/stores/user/user-context.tsx | 79 +++++++-------- 24 files changed, 512 insertions(+), 336 deletions(-) create mode 100644 src/app/chat/layout.tsx create mode 100644 src/app/private-room/layout.tsx create mode 100644 src/app/subscription/layout.tsx create mode 100644 src/app/tip/layout.tsx create mode 100644 src/providers/chat-route-providers.tsx create mode 100644 src/providers/payment-route-provider.tsx create mode 100644 src/providers/private-room-route-provider.tsx create mode 100644 src/stores/auth/__tests__/auth-context.test.tsx diff --git a/src/app/chat/components/chat-unlock-dialogs.tsx b/src/app/chat/components/chat-unlock-dialogs.tsx index 0310e8a6..db05ae35 100644 --- a/src/app/chat/components/chat-unlock-dialogs.tsx +++ b/src/app/chat/components/chat-unlock-dialogs.tsx @@ -1,6 +1,11 @@ "use client"; -import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; +import { shallowEqual } from "@xstate/react"; + +import { + useChatDispatch, + useChatSelector, +} from "@/stores/chat/chat-context"; import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; import { HistoryUnlockDialog } from "./history-unlock-dialog"; @@ -19,7 +24,15 @@ export function ChatUnlockDialogs({ onConfirmInsufficientCreditsDialog, includeHistoryUnlock = true, }: ChatUnlockDialogsProps) { - const chatState = useChatState(); + const chatState = useChatSelector( + (state) => ({ + isUnlockingHistory: state.context.isUnlockingHistory, + lockedHistoryCount: state.context.lockedHistoryCount, + unlockHistoryError: state.context.unlockHistoryError, + unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, + }), + shallowEqual, + ); const chatDispatch = useChatDispatch(); return ( diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index dd25e26d..3ed1fc09 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -8,7 +8,7 @@ * - AI:[Avatar] [Content] [占位 spacer] * - 用户:[占位 spacer] [Content] [Avatar] */ -import { useUserState } from "@/stores/user/user-context"; +import { useUserSelector } from "@/stores/user/user-context"; import { MessageAvatar } from "./message-avatar"; import { MessageContent } from "./message-content"; @@ -49,7 +49,7 @@ export function MessageBubble({ onUnlockImageMessage, onOpenImage, }: MessageBubbleProps) { - const { avatarUrl } = useUserState(); + const avatarUrl = useUserSelector((state) => state.context.avatarUrl); if (isFromAI) { return ( diff --git a/src/app/chat/hooks/use-chat-message-limit-banner.ts b/src/app/chat/hooks/use-chat-message-limit-banner.ts index c1aaff51..324c862e 100644 --- a/src/app/chat/hooks/use-chat-message-limit-banner.ts +++ b/src/app/chat/hooks/use-chat-message-limit-banner.ts @@ -4,7 +4,7 @@ import type { LoginStatus } from "@/data/dto/auth"; import { ROUTES } from "@/router/routes"; import { useAppNavigator } from "@/router/use-app-navigator"; import type { ChatUpgradeReason } from "@/stores/chat/chat-state"; -import { useUserState } from "@/stores/user/user-context"; +import { useUserSelector } from "@/stores/user/user-context"; import { getInsufficientCreditsMessageLimitView, @@ -31,7 +31,7 @@ export function useChatMessageLimitBanner({ loginStatus, }: UseChatMessageLimitBannerInput): ChatMessageLimitBannerView { const navigator = useAppNavigator(); - const userState = useUserState(); + const isVip = useUserSelector((state) => state.context.isVip); const view = getInsufficientCreditsMessageLimitView(loginStatus); function unlock(): void { @@ -41,7 +41,7 @@ export function useChatMessageLimitBanner({ } navigator.openSubscription({ - type: getInsufficientCreditsSubscriptionType(userState.isVip), + type: getInsufficientCreditsSubscriptionType(isVip), returnTo: "chat", }); } diff --git a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts b/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts index 292b0fb1..21a0f769 100644 --- a/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts +++ b/src/app/chat/hooks/use-chat-unlock-navigation-flow.ts @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { shallowEqual } from "@xstate/react"; import type { ChatLockType } from "@/data/schemas/chat"; import { @@ -11,9 +12,12 @@ import { type PendingChatPromotion, } from "@/lib/navigation/chat_unlock_session"; import { useAppNavigator } from "@/router/use-app-navigator"; -import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; +import { + useChatDispatch, + useChatSelector, +} from "@/stores/chat/chat-context"; import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state"; -import { useUserState } from "@/stores/user/user-context"; +import { useUserSelector } from "@/stores/user/user-context"; import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers"; @@ -55,8 +59,14 @@ export function useChatUnlockNavigationFlow({ enabled = true, }: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput { const navigator = useAppNavigator(); - const userState = useUserState(); - const chatState = useChatState(); + const isVip = useUserSelector((state) => state.context.isVip); + const chatState = useChatSelector( + (state) => ({ + historyLoaded: state.context.historyLoaded, + unlockPaywallRequest: state.context.unlockPaywallRequest, + }), + shallowEqual, + ); const chatDispatch = useChatDispatch(); const unlockPaywallRequest = getScopedUnlockPaywallRequest({ request: chatState.unlockPaywallRequest, @@ -161,7 +171,7 @@ export function useChatUnlockNavigationFlow({ clientLockId: unlockPaywallRequest.clientLockId, promotion: unlockPaywallRequest.promotion, returnUrl, - type: getInsufficientCreditsSubscriptionType(userState.isVip), + type: getInsufficientCreditsSubscriptionType(isVip), }); } diff --git a/src/app/chat/hooks/use-first-recharge-offer-banner.ts b/src/app/chat/hooks/use-first-recharge-offer-banner.ts index 3f0b273b..a3c7044b 100644 --- a/src/app/chat/hooks/use-first-recharge-offer-banner.ts +++ b/src/app/chat/hooks/use-first-recharge-offer-banner.ts @@ -1,11 +1,12 @@ "use client"; import { useEffect, useState } from "react"; +import { shallowEqual } from "@xstate/react"; import type { LoginStatus } from "@/data/dto/auth"; import { useAppNavigator } from "@/router/use-app-navigator"; -import { usePaymentDispatch, usePaymentState } from "@/stores/payment"; -import { useUserState } from "@/stores/user/user-context"; +import { usePaymentDispatch, usePaymentSelector } from "@/stores/payment"; +import { useUserSelector } from "@/stores/user/user-context"; import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; import { getFirstRechargeOfferBannerDismissed, @@ -55,12 +56,24 @@ export function useFirstRechargeOfferBanner({ loginStatus, }: UseFirstRechargeOfferBannerInput): UseFirstRechargeOfferBannerOutput { const navigator = useAppNavigator(); - const payment = usePaymentState(); + const payment = usePaymentSelector( + (state) => ({ + isFirstRecharge: state.context.isFirstRecharge, + status: String(state.value), + }), + shallowEqual, + ); const paymentDispatch = usePaymentDispatch(); - const userState = useUserState(); + const user = useUserSelector( + (state) => ({ + countryCode: state.context.currentUser?.countryCode, + id: state.context.currentUser?.id ?? null, + }), + shallowEqual, + ); const isAuthenticatedUser = loginStatus !== "notLoggedIn" && loginStatus !== "guest"; - const userId = userState.currentUser?.id ?? null; + const userId = user.id; const hasUserIdentity = !isAuthenticatedUser || Boolean(userId); const [dismissalState, setDismissalState] = useState(() => ({ userId, @@ -73,14 +86,14 @@ export function useFirstRechargeOfferBanner({ if (!isAuthenticatedUser) return; if (payment.status !== "idle") return; const defaultPayChannel = getDefaultPayChannelForCountryCode( - userState.currentUser?.countryCode, + user.countryCode, ); paymentDispatch({ type: "PaymentInit", payChannel: defaultPayChannel }); }, [ payment.status, paymentDispatch, isAuthenticatedUser, - userState.currentUser?.countryCode, + user.countryCode, ]); useEffect(() => { diff --git a/src/app/chat/layout.tsx b/src/app/chat/layout.tsx new file mode 100644 index 00000000..275a186c --- /dev/null +++ b/src/app/chat/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +import { ChatRouteProviders } from "@/providers/chat-route-providers"; + +export default function ChatLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/app/private-room/layout.tsx b/src/app/private-room/layout.tsx new file mode 100644 index 00000000..c1d8fd23 --- /dev/null +++ b/src/app/private-room/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { PrivateRoomRouteProvider } from "@/providers/private-room-route-provider"; + +export default function PrivateRoomLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/app/subscription/layout.tsx b/src/app/subscription/layout.tsx new file mode 100644 index 00000000..f544846e --- /dev/null +++ b/src/app/subscription/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import { PaymentRouteProvider } from "@/providers/payment-route-provider"; + +export default function SubscriptionLayout({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/app/tip/layout.tsx b/src/app/tip/layout.tsx new file mode 100644 index 00000000..4c896abc --- /dev/null +++ b/src/app/tip/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react"; + +import { PaymentRouteProvider } from "@/providers/payment-route-provider"; + +export default function TipLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/src/providers/chat-route-providers.tsx b/src/providers/chat-route-providers.tsx new file mode 100644 index 00000000..0751c7f2 --- /dev/null +++ b/src/providers/chat-route-providers.tsx @@ -0,0 +1,24 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { ChatProvider } from "@/stores/chat/chat-context"; +import { PaymentProvider } from "@/stores/payment"; +import { + ChatAuthSync, + ChatPaymentSuccessSync, + PaymentSuccessSync, +} from "@/stores/sync"; + +export function ChatRouteProviders({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + + ); +} diff --git a/src/providers/payment-route-provider.tsx b/src/providers/payment-route-provider.tsx new file mode 100644 index 00000000..04fa0287 --- /dev/null +++ b/src/providers/payment-route-provider.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { PaymentProvider } from "@/stores/payment"; +import { PaymentSuccessSync } from "@/stores/sync"; + +export function PaymentRouteProvider({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/src/providers/private-room-route-provider.tsx b/src/providers/private-room-route-provider.tsx new file mode 100644 index 00000000..09bb2677 --- /dev/null +++ b/src/providers/private-room-route-provider.tsx @@ -0,0 +1,13 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { PrivateRoomProvider } from "@/stores/private-room"; + +export function PrivateRoomRouteProvider({ + children, +}: { + children: ReactNode; +}) { + return {children}; +} diff --git a/src/providers/root-providers.tsx b/src/providers/root-providers.tsx index b4edf8a9..b488a0c2 100644 --- a/src/providers/root-providers.tsx +++ b/src/providers/root-providers.tsx @@ -3,15 +3,8 @@ /** * 根级 Client Providers 包装 * - * 把所有功能 Provider 串起来: - * AuthProvider → UserProvider → PaymentProvider → ChatProvider → PrivateRoomProvider - * + AuthStatusChecker(启动时一次:派发 AuthInit) - * + OAuthSessionSync (持续监听 NextAuth session → auth machine) - * + UserAuthSync (auth 登录态恢复后 hydrate user machine) - * + ChatAuthSync (持续监听 auth loginStatus → chat machine) - * - * 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可, - * 无需在每个页面单独包裹。 + * 这里只保留跨路由的 Auth、User 和 viewport Provider。Chat、Payment、 + * PrivateRoom 状态机由对应路由 layout 按需挂载。 */ import type { ReactNode } from "react"; @@ -21,14 +14,7 @@ import { AppNavigationGuard } from "@/router/app-navigation-guard"; import { AuthProvider } from "@/stores/auth/auth-context"; import { AuthStatusChecker } from "@/stores/auth/auth-status-checker"; import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync"; -import { ChatProvider } from "@/stores/chat/chat-context"; -import { PaymentProvider } from "@/stores/payment"; -import { PrivateRoomProvider } from "@/stores/private-room"; -import { - ChatAuthSync, - PaymentSuccessSync, - UserAuthSync, -} from "@/stores/sync"; +import { UserAuthSync } from "@/stores/sync"; import { UserProvider } from "@/stores/user/user-context"; export interface RootProvidersProps { @@ -39,24 +25,13 @@ export function RootProviders({ children }: RootProvidersProps) { return ( - {/* AuthStatusChecker 必须在 AuthProvider 内部(依赖 useAuthDispatch), - * 必须在 UserProvider/ChatProvider 外部(它们可能在 init 完成前就 mount) */} - {/* OAuthSessionSync 同样依赖 AuthProvider 上下文 */} - - - - - - {children} -
- - - + {children} +
diff --git a/src/router/app-navigation-guard.tsx b/src/router/app-navigation-guard.tsx index c4f097e8..abcc2982 100644 --- a/src/router/app-navigation-guard.tsx +++ b/src/router/app-navigation-guard.tsx @@ -2,13 +2,24 @@ import { useEffect } from "react"; import { usePathname, useRouter } from "next/navigation"; +import { shallowEqual } from "@xstate/react"; -import { useAuthState } from "@/stores/auth/auth-context"; +import { + selectAuthIsLoading, + useAuthSelector, +} from "@/stores/auth/auth-context"; import { resolveRouteGuardRedirect } from "./navigation-resolver"; export function AppNavigationGuard() { - const authState = useAuthState(); + const authState = useAuthSelector( + (state) => ({ + hasInitialized: state.context.hasInitialized, + isLoading: selectAuthIsLoading(state), + loginStatus: state.context.loginStatus, + }), + shallowEqual, + ); const pathname = usePathname(); const router = useRouter(); diff --git a/src/router/use-app-navigator.ts b/src/router/use-app-navigator.ts index f1e314f3..aa4d01b1 100644 --- a/src/router/use-app-navigator.ts +++ b/src/router/use-app-navigator.ts @@ -10,8 +10,8 @@ import { peekSubscriptionExplicitExitUrl, } from "@/lib/navigation/subscription_exit"; import { getDefaultPayChannelForCountryCode } from "@/lib/payment/default_pay_channel"; -import { useAuthState } from "@/stores/auth/auth-context"; -import { useUserState } from "@/stores/user/user-context"; +import { useAuthSelector } from "@/stores/auth/auth-context"; +import { useUserSelector } from "@/stores/user/user-context"; import { ROUTE_BUILDERS, @@ -47,9 +47,11 @@ export interface AppNavigator { export function useAppNavigator(): AppNavigator { const router = useRouter(); - const authState = useAuthState(); - const userState = useUserState(); - const isAuthenticatedUser = resolveIsAuthenticatedUser(authState.loginStatus); + const loginStatus = useAuthSelector((state) => state.context.loginStatus); + const countryCode = useUserSelector( + (state) => state.context.currentUser?.countryCode, + ); + const isAuthenticatedUser = resolveIsAuthenticatedUser(loginStatus); const push = useCallback((href: string, options?: { scroll?: boolean }): void => { router.push(href, options); @@ -77,8 +79,8 @@ export function useAppNavigator(): AppNavigator { }, [router]); const getDefaultPayChannel = useCallback(() => { - return getDefaultPayChannelForCountryCode(userState.currentUser?.countryCode); - }, [userState.currentUser?.countryCode]); + return getDefaultPayChannelForCountryCode(countryCode); + }, [countryCode]); const openSubscription = useCallback( ({ @@ -93,7 +95,7 @@ export function useAppNavigator(): AppNavigator { }); const nextUrl = resolveAuthenticatedNavigation({ - loginStatus: authState.loginStatus, + loginStatus, targetUrl: target, }); @@ -103,7 +105,7 @@ export function useAppNavigator(): AppNavigator { } router.push(nextUrl); }, - [authState.loginStatus, getDefaultPayChannel, router], + [getDefaultPayChannel, loginStatus, router], ); const startMessageUnlock = useCallback( diff --git a/src/stores/auth/__tests__/auth-context.test.tsx b/src/stores/auth/__tests__/auth-context.test.tsx new file mode 100644 index 00000000..4be0dbe4 --- /dev/null +++ b/src/stores/auth/__tests__/auth-context.test.tsx @@ -0,0 +1,66 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + AuthProvider, + useAuthDispatch, + useAuthSelector, +} from "@/stores/auth/auth-context"; + +let container: HTMLDivElement | null = null; +let root: ReturnType | null = null; + +afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + container?.remove(); + container = null; + root = null; +}); + +describe("AuthProvider selectors", () => { + it("does not rerender a login-status subscriber for panel-only changes", () => { + let renderCount = 0; + + function Probe() { + const loginStatus = useAuthSelector( + (state) => state.context.loginStatus, + ); + const dispatch = useAuthDispatch(); + renderCount += 1; + + return ( + + ); + } + + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + + act(() => { + root?.render( + + + , + ); + }); + expect(renderCount).toBe(1); + + act(() => { + container?.querySelector("button")?.click(); + }); + + expect(renderCount).toBe(1); + expect(container.textContent).toBe("notLoggedIn"); + }); +}); diff --git a/src/stores/auth/auth-context.tsx b/src/stores/auth/auth-context.tsx index 7c3486d9..765f86bb 100644 --- a/src/stores/auth/auth-context.tsx +++ b/src/stores/auth/auth-context.tsx @@ -1,21 +1,10 @@ "use client"; /** - * AuthContext:基于 XState v5 的 React Context Provider - * - * 业务事件(`Auth*Submitted`)通过 `useMachine(authMachine).send()` 派发。 - * XState 自动处理异步副作用(invoke + fromPromise actors)。 - * - * 兼容性:保持原 `useAuthState()` / `useAuthDispatch()` API, - * 内部将 XState 状态机快照映射回原 `AuthState` 形状。 + * AuthContext:基于 XState actor context 和 selector 的 React Provider。 */ -import { - type Dispatch, - type ReactNode, - createContext, - useContext, - useMemo, -} from "react"; -import { useMachine } from "@xstate/react"; +import type { Dispatch, ReactNode } from "react"; +import { createActorContext, shallowEqual } from "@xstate/react"; +import type { SnapshotFrom } from "xstate"; import { authMachine } from "./auth-machine"; import type { AuthEvent, AuthState as MachineContext } from "./auth-machine"; @@ -33,54 +22,53 @@ interface AuthState { hasInitialized: MachineContext["hasInitialized"]; } -const AuthStateCtx = createContext(null); -const AuthDispatchCtx = createContext | null>(null); +type AuthSnapshot = SnapshotFrom; +type AuthSelector = (snapshot: AuthSnapshot) => T; + +const AuthActorContext = createActorContext(authMachine); export interface AuthProviderProps { children: ReactNode; } export function AuthProvider({ children }: AuthProviderProps) { - const [state, send] = useMachine(authMachine); - - // 映射 XState 状态机快照 → 原 AuthState 形状 - const authState = useMemo( - () => ({ - authPanelMode: state.context.authPanelMode, - // isLoading 覆盖邮箱登录 / 邮箱注册 / OAuth 跳转(NextAuth 重定向期间)/ - // OAuth 回调后端 sync / 显式游客登录 - isLoading: - state.matches("loadingEmailLogin") || - state.matches("loadingEmailRegister") || - state.matches("loadingOAuth") || - state.matches("initializing") || - state.matches("loggingOut") || - state.matches("syncingGoogleBackend") || - state.matches("syncingFacebookBackend") || - state.matches("loadingGuestLogin"), - errorMessage: state.context.errorMessage, - loginStatus: state.context.loginStatus, - hasInitialized: state.context.hasInitialized, - }), - [state], - ); - - return ( - - {children} - - ); + return {children}; } export function useAuthState(): AuthState { - const ctx = useContext(AuthStateCtx); - if (!ctx) throw new Error("useAuthState must be used inside "); - return ctx; + return useAuthSelector(selectAuthState, shallowEqual); } export function useAuthDispatch(): Dispatch { - const ctx = useContext(AuthDispatchCtx); - if (!ctx) - throw new Error("useAuthDispatch must be used inside "); - return ctx; + return AuthActorContext.useActorRef().send; +} + +export function useAuthSelector( + selector: AuthSelector, + compare?: (previous: T, next: T) => boolean, +): T { + return AuthActorContext.useSelector(selector, compare); +} + +function selectAuthState(state: AuthSnapshot): AuthState { + return { + authPanelMode: state.context.authPanelMode, + isLoading: selectAuthIsLoading(state), + errorMessage: state.context.errorMessage, + loginStatus: state.context.loginStatus, + hasInitialized: state.context.hasInitialized, + }; +} + +export function selectAuthIsLoading(state: AuthSnapshot): boolean { + return ( + state.matches("loadingEmailLogin") || + state.matches("loadingEmailRegister") || + state.matches("loadingOAuth") || + state.matches("initializing") || + state.matches("loggingOut") || + state.matches("syncingGoogleBackend") || + state.matches("syncingFacebookBackend") || + state.matches("loadingGuestLogin") + ); } diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 6e2cd78d..02150ea5 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -1,13 +1,8 @@ "use client"; -import { - type Dispatch, - type ReactNode, - createContext, - useContext, - useMemo, -} from "react"; -import { useMachine } from "@xstate/react"; +import { type Dispatch, type ReactNode, useMemo } from "react"; +import { createActorContext, shallowEqual } from "@xstate/react"; +import type { SnapshotFrom } from "xstate"; import { chatMachine } from "./chat-machine"; import type { ChatEvent, ChatState as MachineContext } from "./chat-machine"; @@ -38,57 +33,56 @@ interface ChatState { unlockPaywallRequest: MachineContext["unlockPaywallRequest"]; } -const ChatStateCtx = createContext(null); -const ChatDispatchCtx = createContext | null>(null); +type ChatSnapshot = SnapshotFrom; +type ChatSelector = (snapshot: ChatSnapshot) => T; + +const ChatActorContext = createActorContext(chatMachine); export interface ChatProviderProps { children: ReactNode; } export function ChatProvider({ children }: ChatProviderProps) { - const [state, send] = useMachine(chatMachine, {}); - - // 映射 XState 状态机快照 → 原 ChatState 形状 - const chatState = useMemo( - () => ({ - messages: appendPromotionMessage( - state.context.messages, - state.context.promotion, - ), - historyMessages: state.context.messages, - promotion: state.context.promotion, - isReplyingAI: state.context.isReplyingAI, - upgradePromptVisible: state.context.upgradePromptVisible, - upgradeReason: state.context.upgradeReason, - historyLoaded: state.context.historyLoaded, - unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, - lockedHistoryCount: state.context.lockedHistoryCount, - isUnlockingHistory: state.context.isUnlockingHistory, - unlockHistoryError: state.context.unlockHistoryError, - isUnlockingMessage: state.context.isUnlockingMessage, - unlockingMessageId: state.context.unlockingMessageId, - unlockMessageError: state.context.unlockMessageError, - unlockPaywallRequest: state.context.unlockPaywallRequest, - }), - [state], - ); - - return ( - - {children} - - ); + return {children}; } export function useChatState(): ChatState { - const ctx = useContext(ChatStateCtx); - if (!ctx) throw new Error("useChatState must be used inside "); - return ctx; + const selected = useChatSelector(selectChatState, shallowEqual); + const messages = useMemo( + () => appendPromotionMessage(selected.historyMessages, selected.promotion), + [selected.historyMessages, selected.promotion], + ); + return useMemo(() => ({ ...selected, messages }), [messages, selected]); } export function useChatDispatch(): Dispatch { - const ctx = useContext(ChatDispatchCtx); - if (!ctx) - throw new Error("useChatDispatch must be used inside "); - return ctx; + return ChatActorContext.useActorRef().send; +} + +export function useChatSelector( + selector: ChatSelector, + compare?: (previous: T, next: T) => boolean, +): T { + return ChatActorContext.useSelector(selector, compare); +} + +type SelectedChatState = Omit; + +function selectChatState(state: ChatSnapshot): SelectedChatState { + return { + historyMessages: state.context.messages, + promotion: state.context.promotion, + isReplyingAI: state.context.isReplyingAI, + upgradePromptVisible: state.context.upgradePromptVisible, + upgradeReason: state.context.upgradeReason, + historyLoaded: state.context.historyLoaded, + unlockHistoryPromptVisible: state.context.unlockHistoryPromptVisible, + lockedHistoryCount: state.context.lockedHistoryCount, + isUnlockingHistory: state.context.isUnlockingHistory, + unlockHistoryError: state.context.unlockHistoryError, + isUnlockingMessage: state.context.isUnlockingMessage, + unlockingMessageId: state.context.unlockingMessageId, + unlockMessageError: state.context.unlockMessageError, + unlockPaywallRequest: state.context.unlockPaywallRequest, + }; } diff --git a/src/stores/payment/payment-context.tsx b/src/stores/payment/payment-context.tsx index 75f52dad..470074a0 100644 --- a/src/stores/payment/payment-context.tsx +++ b/src/stores/payment/payment-context.tsx @@ -3,14 +3,9 @@ /** * PaymentContext:基于 XState v5 的 React Context Provider */ -import { - type Dispatch, - type ReactNode, - createContext, - useContext, - useMemo, -} from "react"; -import { useMachine } from "@xstate/react"; +import type { Dispatch, ReactNode } from "react"; +import { createActorContext, shallowEqual } from "@xstate/react"; +import type { SnapshotFrom } from "xstate"; import { paymentMachine } from "./payment-machine"; import type { @@ -37,61 +32,55 @@ export interface PaymentContextState { isPaid: boolean; } -const PaymentStateCtx = createContext(null); -const PaymentDispatchCtx = createContext | null>(null); +type PaymentSnapshot = SnapshotFrom; +type PaymentSelector = (snapshot: PaymentSnapshot) => T; + +const PaymentActorContext = createActorContext(paymentMachine); export interface PaymentProviderProps { children: ReactNode; } export function PaymentProvider({ children }: PaymentProviderProps) { - const [state, send] = useMachine(paymentMachine); - - const paymentState = useMemo( - () => ({ - status: String(state.value), - plans: state.context.plans, - isFirstRecharge: state.context.isFirstRecharge, - selectedPlanId: state.context.selectedPlanId, - payChannel: state.context.payChannel, - autoRenew: state.context.autoRenew, - agreed: state.context.agreed, - currentOrderId: state.context.currentOrderId, - payParams: state.context.payParams, - orderStatus: state.context.orderStatus, - errorMessage: state.context.errorMessage, - launchNonce: state.context.launchNonce, - isLoadingPlans: - state.matches("loadingCachedPlans") || - state.matches("loadingPlans") || - state.matches("refreshingPlans"), - isCreatingOrder: state.matches("creatingOrder"), - isPollingOrder: - state.matches("pollingOrder") || state.matches("waitingForPayment"), - isPaid: state.matches("paid"), - }), - [state], - ); - - return ( - - - {children} - - - ); + return {children}; } export function usePaymentState(): PaymentContextState { - const ctx = useContext(PaymentStateCtx); - if (!ctx) - throw new Error("usePaymentState must be used inside "); - return ctx; + return usePaymentSelector(selectPaymentState, shallowEqual); } export function usePaymentDispatch(): Dispatch { - const ctx = useContext(PaymentDispatchCtx); - if (!ctx) - throw new Error("usePaymentDispatch must be used inside "); - return ctx; + return PaymentActorContext.useActorRef().send; +} + +export function usePaymentSelector( + selector: PaymentSelector, + compare?: (previous: T, next: T) => boolean, +): T { + return PaymentActorContext.useSelector(selector, compare); +} + +function selectPaymentState(state: PaymentSnapshot): PaymentContextState { + return { + status: String(state.value), + plans: state.context.plans, + isFirstRecharge: state.context.isFirstRecharge, + selectedPlanId: state.context.selectedPlanId, + payChannel: state.context.payChannel, + autoRenew: state.context.autoRenew, + agreed: state.context.agreed, + currentOrderId: state.context.currentOrderId, + payParams: state.context.payParams, + orderStatus: state.context.orderStatus, + errorMessage: state.context.errorMessage, + launchNonce: state.context.launchNonce, + isLoadingPlans: + state.matches("loadingCachedPlans") || + state.matches("loadingPlans") || + state.matches("refreshingPlans"), + isCreatingOrder: state.matches("creatingOrder"), + isPollingOrder: + state.matches("pollingOrder") || state.matches("waitingForPayment"), + isPaid: state.matches("paid"), + }; } diff --git a/src/stores/private-room/private-room-context.tsx b/src/stores/private-room/private-room-context.tsx index 533916fa..52b20b78 100644 --- a/src/stores/private-room/private-room-context.tsx +++ b/src/stores/private-room/private-room-context.tsx @@ -1,13 +1,8 @@ "use client"; -import { - type Dispatch, - type ReactNode, - createContext, - useContext, - useMemo, -} from "react"; -import { useMachine } from "@xstate/react"; +import type { Dispatch, ReactNode } from "react"; +import { createActorContext, shallowEqual } from "@xstate/react"; +import type { SnapshotFrom } from "xstate"; import { privateRoomMachine } from "./private-room-machine"; import type { @@ -33,64 +28,54 @@ export interface PrivateRoomContextState { isUnlocking: boolean; } -const PrivateRoomStateCtx = createContext(null); -const PrivateRoomDispatchCtx = createContext | null>( - null, -); +type PrivateRoomSnapshot = SnapshotFrom; +type PrivateRoomSelector = (snapshot: PrivateRoomSnapshot) => T; + +const PrivateRoomActorContext = createActorContext(privateRoomMachine); export interface PrivateRoomProviderProps { children: ReactNode; } export function PrivateRoomProvider({ children }: PrivateRoomProviderProps) { - const [state, send] = useMachine(privateRoomMachine); - - const privateRoomState = useMemo( - () => ({ - status: String(state.value), - profile: state.context.profile, - items: state.context.items, - nextCursor: state.context.nextCursor, - hasMore: state.context.hasMore, - creditBalance: state.context.creditBalance, - errorMessage: state.context.errorMessage, - unlockingMomentId: state.context.unlockingMomentId, - unlockErrorMessage: state.context.unlockErrorMessage, - pendingConfirmMomentId: state.context.pendingConfirmMomentId, - unlockPaywallRequest: state.context.unlockPaywallRequest, - unlockSuccessNonce: state.context.unlockSuccessNonce, - isLoading: state.matches("loading"), - isLoadingMore: state.matches("loadingMore"), - isUnlocking: state.matches("unlocking"), - }), - [state], - ); - return ( - - - {children} - - + {children} ); } export function usePrivateRoomState(): PrivateRoomContextState { - const ctx = useContext(PrivateRoomStateCtx); - if (!ctx) { - throw new Error( - "usePrivateRoomState must be used inside ", - ); - } - return ctx; + return usePrivateRoomSelector(selectPrivateRoomState, shallowEqual); } export function usePrivateRoomDispatch(): Dispatch { - const ctx = useContext(PrivateRoomDispatchCtx); - if (!ctx) { - throw new Error( - "usePrivateRoomDispatch must be used inside ", - ); - } - return ctx; + return PrivateRoomActorContext.useActorRef().send; +} + +export function usePrivateRoomSelector( + selector: PrivateRoomSelector, + compare?: (previous: T, next: T) => boolean, +): T { + return PrivateRoomActorContext.useSelector(selector, compare); +} + +function selectPrivateRoomState( + state: PrivateRoomSnapshot, +): PrivateRoomContextState { + return { + status: String(state.value), + profile: state.context.profile, + items: state.context.items, + nextCursor: state.context.nextCursor, + hasMore: state.context.hasMore, + creditBalance: state.context.creditBalance, + errorMessage: state.context.errorMessage, + unlockingMomentId: state.context.unlockingMomentId, + unlockErrorMessage: state.context.unlockErrorMessage, + pendingConfirmMomentId: state.context.pendingConfirmMomentId, + unlockPaywallRequest: state.context.unlockPaywallRequest, + unlockSuccessNonce: state.context.unlockSuccessNonce, + isLoading: state.matches("loading"), + isLoadingMore: state.matches("loadingMore"), + isUnlocking: state.matches("unlocking"), + }; } diff --git a/src/stores/sync/chat-auth-sync.tsx b/src/stores/sync/chat-auth-sync.tsx index 3e4b23a9..93e57cb6 100644 --- a/src/stores/sync/chat-auth-sync.tsx +++ b/src/stores/sync/chat-auth-sync.tsx @@ -8,13 +8,24 @@ * 不再承担全局状态同步职责。 */ import { useEffect, useRef } from "react"; +import { shallowEqual } from "@xstate/react"; import { AuthStorage } from "@/data/storage/auth"; -import { useAuthState } from "@/stores/auth/auth-context"; +import { + selectAuthIsLoading, + useAuthSelector, +} from "@/stores/auth/auth-context"; import { useChatDispatch } from "@/stores/chat/chat-context"; export function ChatAuthSync() { - const authState = useAuthState(); + const authState = useAuthSelector( + (state) => ({ + hasInitialized: state.context.hasInitialized, + isLoading: selectAuthIsLoading(state), + loginStatus: state.context.loginStatus, + }), + shallowEqual, + ); const chatDispatch = useChatDispatch(); const prevSessionKeyRef = useRef(null); diff --git a/src/stores/sync/payment-success-sync.tsx b/src/stores/sync/payment-success-sync.tsx index 075c536f..eb6afe24 100644 --- a/src/stores/sync/payment-success-sync.tsx +++ b/src/stores/sync/payment-success-sync.tsx @@ -1,28 +1,33 @@ "use client"; /** - * Payment → User / Chat 同步器 + * Payment → User 同步器 * - * 支付成功是跨模块事件: - * - User 需要重新拉取 profile + entitlements,刷新 isVip / creditBalance。 - * - Chat 需要根据历史锁定消息数量决定直接解锁或弹窗确认。 + * 支付成功后刷新用户权益、清理套餐缓存,并消费首充标记。 */ import { useEffect, useRef } from "react"; +import { shallowEqual } from "@xstate/react"; import { useChatDispatch } from "@/stores/chat/chat-context"; import { useUserDispatch } from "@/stores/user/user-context"; import { hasPendingChatUnlock } from "@/lib/navigation/chat_unlock_session"; import { usePaymentDispatch, - usePaymentState, + usePaymentSelector, } from "@/stores/payment/payment-context"; import { getPaymentRepository } from "@/data/repositories/payment_repository"; export function PaymentSuccessSync() { - const paymentState = usePaymentState(); + const paymentState = usePaymentSelector( + (state) => ({ + currentOrderId: state.context.currentOrderId, + isPaid: state.matches("paid"), + orderStatus: state.context.orderStatus, + }), + shallowEqual, + ); const paymentDispatch = usePaymentDispatch(); const userDispatch = useUserDispatch(); - const chatDispatch = useChatDispatch(); const lastPaidKeyRef = useRef(null); useEffect(() => { @@ -33,22 +38,13 @@ export function PaymentSuccessSync() { lastPaidKeyRef.current = paidKey; paymentDispatch({ type: "PaymentFirstRechargeConsumed" }); - let cancelled = false; - const syncPaymentSuccess = async () => { await getPaymentRepository().clearCachedPlans(); userDispatch({ type: "UserFetch" }); - if (await hasPendingChatUnlock()) return; - if (cancelled) return; - chatDispatch({ type: "ChatPaymentSucceeded" }); }; void syncPaymentSuccess(); - return () => { - cancelled = true; - }; }, [ - chatDispatch, paymentState.currentOrderId, paymentState.isPaid, paymentState.orderStatus, @@ -58,3 +54,42 @@ export function PaymentSuccessSync() { return null; } + +/** Payment → Chat bridge, mounted only while the chat route is active. */ +export function ChatPaymentSuccessSync() { + const paymentState = usePaymentSelector( + (state) => ({ + currentOrderId: state.context.currentOrderId, + isPaid: state.matches("paid"), + orderStatus: state.context.orderStatus, + }), + shallowEqual, + ); + const chatDispatch = useChatDispatch(); + const lastPaidKeyRef = useRef(null); + + useEffect(() => { + if (!paymentState.isPaid || !paymentState.currentOrderId) return; + + const paidKey = `${paymentState.currentOrderId}:${paymentState.orderStatus}`; + if (lastPaidKeyRef.current === paidKey) return; + lastPaidKeyRef.current = paidKey; + + let cancelled = false; + void (async () => { + if (await hasPendingChatUnlock()) return; + if (!cancelled) chatDispatch({ type: "ChatPaymentSucceeded" }); + })(); + + return () => { + cancelled = true; + }; + }, [ + chatDispatch, + paymentState.currentOrderId, + paymentState.isPaid, + paymentState.orderStatus, + ]); + + return null; +} diff --git a/src/stores/sync/user-auth-sync.tsx b/src/stores/sync/user-auth-sync.tsx index 3759bb2a..400f66c3 100644 --- a/src/stores/sync/user-auth-sync.tsx +++ b/src/stores/sync/user-auth-sync.tsx @@ -9,13 +9,24 @@ * - guest / email / OAuth → 重新 hydrate user store */ import { useEffect, useRef } from "react"; +import { shallowEqual } from "@xstate/react"; import type { LoginStatus } from "@/data/dto/auth"; -import { useAuthState } from "@/stores/auth/auth-context"; +import { + selectAuthIsLoading, + useAuthSelector, +} from "@/stores/auth/auth-context"; import { useUserDispatch } from "@/stores/user/user-context"; export function UserAuthSync() { - const { hasInitialized, isLoading, loginStatus } = useAuthState(); + const { hasInitialized, isLoading, loginStatus } = useAuthSelector( + (state) => ({ + hasInitialized: state.context.hasInitialized, + isLoading: selectAuthIsLoading(state), + loginStatus: state.context.loginStatus, + }), + shallowEqual, + ); const userDispatch = useUserDispatch(); const prevLoginStatusRef = useRef(null); diff --git a/src/stores/user/user-context.tsx b/src/stores/user/user-context.tsx index c0aaf565..74aa9053 100644 --- a/src/stores/user/user-context.tsx +++ b/src/stores/user/user-context.tsx @@ -1,21 +1,10 @@ "use client"; /** - * UserContext:基于 XState v5 的 React Context Provider - * - * 业务事件通过 `useMachine(userMachine).send()` 派发。 - * XState 自动处理异步副作用(invoke + fromPromise actors)。 - * - * 兼容性:保持原 `useUserState()` / `useUserDispatch()` API。 - * 内部将 XState 状态机快照映射回原 `UserState` 形状。 + * UserContext:基于 XState actor context 和 selector 的 React Provider。 */ -import { - type Dispatch, - type ReactNode, - createContext, - useContext, - useMemo, -} from "react"; -import { useMachine } from "@xstate/react"; +import type { Dispatch, ReactNode } from "react"; +import { createActorContext, shallowEqual } from "@xstate/react"; +import type { SnapshotFrom } from "xstate"; import { userMachine } from "./user-machine"; import type { UserState as MachineContext, UserEvent } from "./user-machine"; @@ -31,48 +20,44 @@ interface UserState { creditBalance: number; } -const UserStateCtx = createContext(null); -const UserDispatchCtx = createContext | null>(null); +type UserSnapshot = SnapshotFrom; +type UserSelector = (snapshot: UserSnapshot) => T; + +const UserActorContext = createActorContext(userMachine); export interface UserProviderProps { children: ReactNode; } export function UserProvider({ children }: UserProviderProps) { - const [state, send] = useMachine(userMachine); - - // 映射 XState 状态机快照 → 原 UserState 形状 - const userState = useMemo( - () => ({ - currentUser: state.context.currentUser, - isLoading: - state.matches("initializing") || - state.matches("fetching") || - state.matches("loggingOut") || - state.context.isLoading, - avatarUrl: state.context.avatarUrl, - isVip: state.context.isVip, - creditBalance: state.context.creditBalance, - }), - [state], - ); - - return ( - - {children} - - ); + return {children}; } export function useUserState(): UserState { - const ctx = useContext(UserStateCtx); - if (!ctx) throw new Error("useUserState must be used inside "); - return ctx; + return useUserSelector(selectUserState, shallowEqual); } export function useUserDispatch(): Dispatch { - const ctx = useContext(UserDispatchCtx); - if (!ctx) - throw new Error("useUserDispatch must be used inside "); - return ctx; + return UserActorContext.useActorRef().send; +} + +export function useUserSelector( + selector: UserSelector, + compare?: (previous: T, next: T) => boolean, +): T { + return UserActorContext.useSelector(selector, compare); +} + +function selectUserState(state: UserSnapshot): UserState { + return { + currentUser: state.context.currentUser, + isLoading: + state.matches("initializing") || + state.matches("fetching") || + state.matches("loggingOut") || + state.context.isLoading, + avatarUrl: state.context.avatarUrl, + isVip: state.context.isVip, + creditBalance: state.context.creditBalance, + }; }