fix(chat): render cached history before network sync

This commit is contained in:
2026-07-02 11:51:20 +08:00
parent cd25fa35f2
commit 5821a4d062
7 changed files with 339 additions and 114 deletions
@@ -9,15 +9,6 @@ import {
import { chatMachine } from "@/stores/chat/chat-machine"; import { chatMachine } from "@/stores/chat/chat-machine";
import type { ChatEvent } from "@/stores/chat/chat-events"; import type { ChatEvent } from "@/stores/chat/chat-events";
interface LoadHistoryOutput {
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
localOverwritten: boolean;
localCount: number;
networkCount: number;
}
interface LoadMoreHistoryOutput { interface LoadMoreHistoryOutput {
messages: UiMessage[]; messages: UiMessage[];
hasMore: boolean; hasMore: boolean;
@@ -94,14 +85,7 @@ function createTestChatMachine(
) { ) {
return chatMachine.provide({ return chatMachine.provide({
actors: { actors: {
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({ loadHistory: createLoadHistoryCallback(options.historyMessages ?? []),
messages: options.historyMessages ?? [],
hasMore: false,
newOffset: 0,
localOverwritten: true,
localCount: 0,
networkCount: 0,
})),
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>( loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
async () => ({ async () => ({
messages: [], messages: [],
@@ -150,6 +134,35 @@ function createTestChatMachine(
}); });
} }
function createLoadHistoryCallback(
messages: UiMessage[],
networkMessages: UiMessage[] = messages,
) {
return fromCallback<ChatEvent>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
messages,
hasMore: false,
newOffset: messages.length,
localCount: messages.length,
},
});
sendBack({
type: "ChatNetworkHistoryLoaded",
output: {
messages: networkMessages,
hasMore: false,
newOffset: networkMessages.length,
localOverwritten: true,
localCount: messages.length,
networkCount: networkMessages.length,
},
});
return () => undefined;
});
}
describe("chatMachine transitions", () => { describe("chatMachine transitions", () => {
it("keeps guest logout disabled while allowing upgrade to user login", async () => { it("keeps guest logout disabled while allowing upgrade to user login", async () => {
const actor = createActor(createTestChatMachine()).start(); const actor = createActor(createTestChatMachine()).start();
@@ -187,6 +200,76 @@ describe("chatMachine transitions", () => {
actor.stop(); actor.stop();
}); });
it("renders local history before network history sync finishes", async () => {
let resolveNetwork!: () => void;
const networkReleased = new Promise<void>((resolve) => {
resolveNetwork = resolve;
});
const localMessage: UiMessage = {
id: "local-msg",
content: "cached local message",
isFromAI: true,
date: "2026-07-02",
};
const networkMessage: UiMessage = {
id: "network-msg",
content: "fresh network message",
isFromAI: true,
date: "2026-07-02",
};
const machine = chatMachine.provide({
actors: {
loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
sendBack({
type: "ChatLocalHistoryLoaded",
output: {
messages: [localMessage],
hasMore: false,
newOffset: 1,
localCount: 1,
},
});
void networkReleased.then(() => {
sendBack({
type: "ChatNetworkHistoryLoaded",
output: {
messages: [networkMessage],
hasMore: false,
newOffset: 1,
localOverwritten: true,
localCount: 1,
networkCount: 1,
},
});
});
return () => undefined;
}),
},
});
const actor = createActor(machine).start();
actor.send({ type: "ChatUserLogin", token: "token" });
await waitFor(actor, (snapshot) =>
snapshot.matches({ userSession: "ready" }),
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "local-msg", content: "cached local message" },
]);
resolveNetwork();
await waitFor(
actor,
(snapshot) => snapshot.context.messages[0]?.id === "network-msg",
);
expect(actor.getSnapshot().context.messages).toMatchObject([
{ id: "network-msg", content: "fresh network message" },
]);
actor.stop();
});
it("ignores guest login while an authenticated user session is active", async () => { it("ignores guest login while an authenticated user session is active", async () => {
const actor = createActor(createTestChatMachine()).start(); const actor = createActor(createTestChatMachine()).start();
@@ -254,14 +337,7 @@ describe("chatMachine transitions", () => {
it("tracks replying state by queued batch instead of user message count", async () => { it("tracks replying state by queued batch instead of user message count", async () => {
const machine = chatMachine.provide({ const machine = chatMachine.provide({
actors: { actors: {
loadHistory: fromPromise<LoadHistoryOutput>(async () => ({ loadHistory: createLoadHistoryCallback([]),
messages: [],
hasMore: false,
newOffset: 0,
localOverwritten: true,
localCount: 0,
networkCount: 0,
})),
loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>( loadMoreHistory: fromPromise<LoadMoreHistoryOutput, { offset: number }>(
async () => ({ async () => ({
messages: [], messages: [],
+9
View File
@@ -22,6 +22,15 @@ export type ChatEvent =
| { type: "ChatGuestLogin" } | { type: "ChatGuestLogin" }
| { type: "ChatUserLogin"; token: string } | { type: "ChatUserLogin"; token: string }
| { type: "ChatLogout" } | { type: "ChatLogout" }
| {
type: "ChatLocalHistoryLoaded";
output: import("./chat-history-sync").LocalHistorySnapshotOutput;
}
| {
type: "ChatNetworkHistoryLoaded";
output: import("./chat-history-sync").NetworkHistorySyncOutput;
}
| { type: "ChatHistoryLoadFailed"; error: unknown }
// 业务事件 // 业务事件
| { type: "ChatSendMessage"; content: string } | { type: "ChatSendMessage"; content: string }
| { type: "ChatSendImage"; imageBase64: string } | { type: "ChatSendImage"; imageBase64: string }
+34 -11
View File
@@ -1,28 +1,51 @@
import { fromPromise } from "xstate"; import { fromCallback, fromPromise } from "xstate";
import { getChatRepository } from "@/data/repositories/chat_repository"; import { getChatRepository } from "@/data/repositories/chat_repository";
import { Logger, Result } from "@/utils"; import { Logger, Result } from "@/utils";
import { readAndSyncHistory } from "./chat-history-sync"; import {
readLocalHistorySnapshot,
syncNetworkHistory,
} from "./chat-history-sync";
import { import {
PAGE_SIZE, PAGE_SIZE,
localMessagesToUi, localMessagesToUi,
} from "./chat-machine.helpers"; } from "./chat-machine.helpers";
import type { ChatEvent } from "./chat-events";
const log = new Logger("StoresChatChatHistoryFlow"); const log = new Logger("StoresChatChatHistoryFlow");
type UiMessage = import("@/data/dto/chat").UiMessage; type UiMessage = import("@/data/dto/chat").UiMessage;
export const chatHistoryActors = { export const chatHistoryActors = {
loadHistory: fromPromise<{ loadHistory: fromCallback<ChatEvent>(({ sendBack }) => {
messages: UiMessage[]; let cancelled = false;
hasMore: boolean;
newOffset: number; void (async () => {
localOverwritten: boolean; const localSnapshot = await readLocalHistorySnapshot();
localCount: number; if (cancelled) return;
networkCount: number; sendBack({
}>(async () => { type: "ChatLocalHistoryLoaded",
return readAndSyncHistory(); output: localSnapshot,
});
const networkSnapshot = await syncNetworkHistory(
localSnapshot.localCount,
);
if (cancelled || !networkSnapshot) return;
sendBack({
type: "ChatNetworkHistoryLoaded",
output: networkSnapshot,
});
})().catch((error: unknown) => {
if (cancelled) return;
log.error("[chat-machine] loadHistoryActor failed", { error });
sendBack({ type: "ChatHistoryLoadFailed", error });
});
return () => {
cancelled = true;
};
}), }),
loadMoreHistory: fromPromise< loadMoreHistory: fromPromise<
+55 -25
View File
@@ -17,7 +17,17 @@ export type ReadAndSyncHistoryOutput = {
networkCount: number; networkCount: number;
}; };
function createGreetingMessage(): UiMessage { export type LocalHistorySnapshotOutput = {
/** Local cached messages. Empty local history includes a UI greeting. */
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
localCount: number;
};
export type NetworkHistorySyncOutput = ReadAndSyncHistoryOutput;
export function createGreetingMessage(): UiMessage {
return { return {
content: content:
"You're here! Facebook is so restrictive, " + "You're here! Facebook is so restrictive, " +
@@ -28,13 +38,7 @@ function createGreetingMessage(): UiMessage {
}; };
} }
/** export async function readLocalHistorySnapshot(): Promise<LocalHistorySnapshotOutput> {
* History sync flow:
* 1. Read local history for fallback.
* 2. Read network history as the authoritative source.
* 3. Overwrite local history with network data.
*/
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
const chatRepo = getChatRepository(); const chatRepo = getChatRepository();
const greetingMessage = createGreetingMessage(); const greetingMessage = createGreetingMessage();
@@ -47,27 +51,32 @@ export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
count: localMessages.length, count: localMessages.length,
}); });
const snapshotMessages =
localMessages.length === 0 ? [greetingMessage] : localMessages;
if (snapshotMessages[0] === greetingMessage) {
log.debug("[chat-machine] loadHistory EMPTY -> prepend greeting (local)");
}
return {
messages: snapshotMessages,
hasMore: localMessages.length >= PAGE_SIZE,
newOffset: snapshotMessages.length,
localCount: localMessages.length,
};
}
export async function syncNetworkHistory(
localCount: number,
): Promise<NetworkHistorySyncOutput | null> {
const chatRepo = getChatRepository();
const greetingMessage = createGreetingMessage();
const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0); const networkResult = await chatRepo.getHistory(PAGE_SIZE, 0);
if (Result.isErr(networkResult)) { if (Result.isErr(networkResult)) {
log.error("[chat-machine] loadHistory NETWORK FAILED", { log.error("[chat-machine] loadHistory NETWORK FAILED", {
error: networkResult.error, error: networkResult.error,
}); });
return null;
const fallbackMessages =
localMessages.length === 0 ? [greetingMessage] : localMessages;
if (fallbackMessages[0] === greetingMessage) {
log.debug(
"[chat-machine] loadHistory EMPTY -> prepend greeting (network-failed path)",
);
}
return {
messages: fallbackMessages,
hasMore: false,
newOffset: fallbackMessages.length,
localOverwritten: false,
localCount: localMessages.length,
networkCount: 0,
};
} }
const networkUi = localMessagesToUi(networkResult.data.messages); const networkUi = localMessagesToUi(networkResult.data.messages);
@@ -95,7 +104,28 @@ export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
hasMore: finalMessages.length >= PAGE_SIZE, hasMore: finalMessages.length >= PAGE_SIZE,
newOffset: finalMessages.length, newOffset: finalMessages.length,
localOverwritten, localOverwritten,
localCount: localMessages.length, localCount,
networkCount: networkUi.length, networkCount: networkUi.length,
}; };
} }
/**
* History sync flow:
* 1. Read local history for fallback.
* 2. Read network history as the authoritative source.
* 3. Overwrite local history with network data.
*/
export async function readAndSyncHistory(): Promise<ReadAndSyncHistoryOutput> {
const localSnapshot = await readLocalHistorySnapshot();
const networkSnapshot = await syncNetworkHistory(localSnapshot.localCount);
if (networkSnapshot) return networkSnapshot;
return {
messages: localSnapshot.messages,
hasMore: localSnapshot.hasMore,
newOffset: localSnapshot.newOffset,
localOverwritten: false,
localCount: localSnapshot.localCount,
networkCount: 0,
};
}
+20
View File
@@ -26,3 +26,23 @@ export function applyHistoryLoadedOutput(
historyLoaded: true, historyLoaded: true,
}; };
} }
export function applyNetworkHistoryLoadedOutput(
context: ChatState,
output: {
messages: UiMessage[];
hasMore: boolean;
newOffset: number;
},
): Pick<
ChatState,
"messages" | "hasMore" | "historyOffset" | "historyLoaded"
> {
const optimisticTail = context.messages.slice(context.historyOffset);
return {
messages: [...output.messages, ...optimisticTail],
hasMore: output.hasMore,
historyOffset: output.newOffset,
historyLoaded: true,
};
}
+112 -45
View File
@@ -34,6 +34,7 @@ import type { ChatEvent } from "./chat-events";
import { import {
applyHttpSendOutput, applyHttpSendOutput,
applyHistoryLoadedOutput, applyHistoryLoadedOutput,
applyNetworkHistoryLoadedOutput,
countLockedHistoryMessages, countLockedHistoryMessages,
shouldAutoUnlockHistory, shouldAutoUnlockHistory,
shouldPromptUnlockHistory, shouldPromptUnlockHistory,
@@ -78,6 +79,46 @@ export const chatMachine = setup({
clearChatSession: assign(() => ({ clearChatSession: assign(() => ({
...initialState, ...initialState,
})), })),
applyLocalHistoryLoaded: assign(({ event }) => {
if (event.type !== "ChatLocalHistoryLoaded") return {};
return applyHistoryLoadedOutput(event.output);
}),
applyNetworkHistoryLoaded: assign(({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return applyNetworkHistoryLoadedOutput(context, event.output);
}),
applyNetworkHistoryLoadedAndClearPayment: assign(({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
return {
...applyNetworkHistoryLoadedOutput(context, event.output),
isLoadingMore: false,
paymentUnlockPending: false,
};
}),
showUnlockHistoryPromptFromNetwork: assign(({ context, event }) => {
if (event.type !== "ChatNetworkHistoryLoaded") return {};
const nextHistory = applyNetworkHistoryLoadedOutput(
context,
event.output,
);
return {
...nextHistory,
isLoadingMore: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(nextHistory.messages),
unlockHistoryError: null,
};
}),
markHistoryLoadFailed: assign({
isLoadingMore: false,
historyLoaded: true,
}),
}, },
}).createMachine({ }).createMachine({
id: "chat", id: "chat",
@@ -98,15 +139,27 @@ export const chatMachine = setup({
}, },
guestSession: { guestSession: {
invoke: { invoke: [
{
id: "messageQueue", id: "messageQueue",
src: "httpMessageQueue", src: "httpMessageQueue",
}, },
{
id: "loadHistory",
src: "loadHistory",
},
],
on: { on: {
ChatUserLogin: { ChatUserLogin: {
target: "#chat.userSession", target: "#chat.userSession",
actions: "startUserSession", actions: "startUserSession",
}, },
ChatNetworkHistoryLoaded: {
actions: "applyNetworkHistoryLoaded",
},
ChatHistoryLoadFailed: {
actions: "markHistoryLoadFailed",
},
ChatQueuedSendStarted: { ChatQueuedSendStarted: {
actions: "markQueuedSendStarted", actions: "markQueuedSendStarted",
}, },
@@ -120,25 +173,18 @@ export const chatMachine = setup({
initial: "initializing", initial: "initializing",
states: { states: {
initializing: { initializing: {
always: [ on: {
{ ChatLocalHistoryLoaded: {
target: "ready", target: "ready",
guard: ({ context }) => context.historyLoaded, actions: "applyLocalHistoryLoaded",
}, },
], ChatNetworkHistoryLoaded: {
invoke: { target: "ready",
id: "loadHistory", actions: "applyNetworkHistoryLoaded",
src: "loadHistory",
onDone: {
actions: assign(({ event }) =>
applyHistoryLoadedOutput(event.output),
),
}, },
onError: { ChatHistoryLoadFailed: {
// 失败也标 loaded,不卡 init(让 UI 还是能进 ready,屏幕可能空) target: "ready",
actions: assign({ actions: "markHistoryLoadFailed",
historyLoaded: true,
}),
}, },
}, },
}, },
@@ -178,15 +224,50 @@ export const chatMachine = setup({
}, },
userSession: { userSession: {
invoke: { invoke: [
{
id: "messageQueue", id: "messageQueue",
src: "httpMessageQueue", src: "httpMessageQueue",
}, },
{
id: "loadHistory",
src: "loadHistory",
},
],
on: { on: {
ChatLogout: { ChatLogout: {
target: "#chat.idle", target: "#chat.idle",
actions: "clearChatSession", actions: "clearChatSession",
}, },
ChatNetworkHistoryLoaded: [
{
guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending &&
shouldAutoUnlockHistory(event.output.messages),
target: ".unlockingHistory",
actions: [
"applyNetworkHistoryLoadedAndClearPayment",
"markUnlockHistoryStarted",
],
},
{
guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages),
target: ".ready",
actions: "showUnlockHistoryPromptFromNetwork",
},
{
guard: ({ context }) =>
!context.isUnlockingHistory && !context.isUnlockingMessage,
actions: "applyNetworkHistoryLoaded",
},
],
ChatHistoryLoadFailed: {
actions: "markHistoryLoadFailed",
},
ChatQueuedSendStarted: { ChatQueuedSendStarted: {
actions: "markQueuedSendStarted", actions: "markQueuedSendStarted",
}, },
@@ -232,53 +313,39 @@ export const chatMachine = setup({
initial: "initializing", initial: "initializing",
states: { states: {
initializing: { initializing: {
invoke: { on: {
src: "loadHistory", ChatLocalHistoryLoaded: {
onDone: [ target: "ready",
actions: "applyLocalHistoryLoaded",
},
ChatNetworkHistoryLoaded: [
{ {
guard: ({ context, event }) => guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending && context.paymentUnlockPending &&
shouldAutoUnlockHistory(event.output.messages), shouldAutoUnlockHistory(event.output.messages),
target: "unlockingHistory", target: "unlockingHistory",
actions: [ actions: [
assign(({ event }) => ({ "applyNetworkHistoryLoadedAndClearPayment",
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
})),
"markUnlockHistoryStarted", "markUnlockHistoryStarted",
], ],
}, },
{ {
guard: ({ context, event }) => guard: ({ context, event }) =>
event.type === "ChatNetworkHistoryLoaded" &&
context.paymentUnlockPending && context.paymentUnlockPending &&
shouldPromptUnlockHistory(event.output.messages), shouldPromptUnlockHistory(event.output.messages),
target: "ready", target: "ready",
actions: assign(({ event }) => ({ actions: "showUnlockHistoryPromptFromNetwork",
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
paymentUnlockPending: false,
unlockHistoryPromptVisible: true,
lockedHistoryCount: countLockedHistoryMessages(
event.output.messages,
),
unlockHistoryError: null,
})),
}, },
{ {
target: "ready", target: "ready",
actions: assign(({ event }) => ({ actions: "applyNetworkHistoryLoadedAndClearPayment",
...applyHistoryLoadedOutput(event.output),
isLoadingMore: false,
paymentUnlockPending: false,
})),
}, },
], ],
onError: { ChatHistoryLoadFailed: {
target: "ready", target: "ready",
actions: assign({ actions: "markHistoryLoadFailed",
isLoadingMore: false,
historyLoaded: true,
}),
}, },
}, },
}, },
+2 -2
View File
@@ -27,8 +27,8 @@ export interface ChatState {
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;
historyOffset: number; historyOffset: number;
/** history 加载完成标志(initial load —— local → network → save 跑完 /** history 首屏可展示标志(本地快照加载完成即可进入 ready
* - guestSession.initializing / user sessions initializing 走 always barrier * - 网络历史加载完成后会再次刷新消息列表
* - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事) * - 不被 `loadMoreHistoryActor` 设置(翻页是另一码事)
*/ */
historyLoaded: boolean; historyLoaded: boolean;