refactor: scope providers by route and use XState selectors

This commit is contained in:
2026-07-13 19:07:53 +08:00
parent f9b5f22ee9
commit 37ae152abb
24 changed files with 512 additions and 336 deletions
+43 -49
View File
@@ -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<ChatState | null>(null);
const ChatDispatchCtx = createContext<Dispatch<ChatEvent> | null>(null);
type ChatSnapshot = SnapshotFrom<typeof chatMachine>;
type ChatSelector<T> = (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<ChatState>(
() => ({
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 (
<ChatStateCtx.Provider value={chatState}>
<ChatDispatchCtx.Provider value={send}>{children}</ChatDispatchCtx.Provider>
</ChatStateCtx.Provider>
);
return <ChatActorContext.Provider>{children}</ChatActorContext.Provider>;
}
export function useChatState(): ChatState {
const ctx = useContext(ChatStateCtx);
if (!ctx) throw new Error("useChatState must be used inside <ChatProvider>");
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<ChatEvent> {
const ctx = useContext(ChatDispatchCtx);
if (!ctx)
throw new Error("useChatDispatch must be used inside <ChatProvider>");
return ctx;
return ChatActorContext.useActorRef().send;
}
export function useChatSelector<T>(
selector: ChatSelector<T>,
compare?: (previous: T, next: T) => boolean,
): T {
return ChatActorContext.useSelector(selector, compare);
}
type SelectedChatState = Omit<ChatState, "messages">;
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,
};
}