Compare commits

...

2 Commits

Author SHA1 Message Date
Arvin Xu 67d314b089 🐛 fix: seed message:list cache even when replaceMessages store-set is a no-op
Optimistic flows (optimisticUpdateMessageContent / optimisticDeleteMessage[s])
dispatch the mutation into dbMessagesMap first, then call
replaceMessages(server). When the server echo equals the already-applied
optimistic state, the isEqual early-return skipped the store-set AND the
write-through, leaving message:list at the pre-mutation snapshot — a later
remount could hydrate stale content / deleted rows.

Move the write-through ahead of the equality early-return so the cache is
seeded even on a store no-op. Streaming / fetch-sync guards stay inside
#writeThroughMessageCache, so per-token thrash is still avoided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:53:41 +08:00
Arvin Xu 22eddbc474 ️ perf: write message mutations through to the message:list SWR cache
Message mutations only touched the in-memory store, so the message:list
SWR/IndexedDB cache stayed stale until a network refetch. Because the
Conversation store is recreated on every topic/session switch and
re-hydrates from that cache, the stale cache is what forced a refetch on
every switch.

replaceMessages now seeds the message:list cache for the exact bucket via
mutate(matcher, messages, { revalidate: false }). Skips the
useFetchMessages onData sync path (SWR already holds it) and skips while
the context is streaming to avoid per-token IndexedDB thrash; the
agent_runtime_end snapshot still writes through since it clears the
running flag first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:48:48 +08:00
2 changed files with 156 additions and 0 deletions
@@ -1220,6 +1220,104 @@ describe('chatMessage actions', () => {
});
});
describe('replaceMessages cache write-through', () => {
beforeEach(() => {
(mutate as Mock).mockClear();
});
it('seeds the message:list SWR cache for the same bucket without revalidating', async () => {
const { result } = renderHook(() => useChatStore());
const context = { agentId: 'wt-agent', topicId: 'wt-topic' };
const messages = [{ id: 'm1', role: 'user', content: 'hi' }] as any;
await act(async () => {
result.current.replaceMessages(messages, { context });
});
expect(mutate).toHaveBeenCalledTimes(1);
const [matcher, dataArg, options] = (mutate as Mock).mock.calls[0];
// seeds, never refetches
expect(options).toEqual({ revalidate: false });
expect(dataArg).toEqual(messages);
// matcher targets exactly this bucket, not other buckets / domains
expect(typeof matcher).toBe('function');
expect(matcher(['message:list', context, 1])).toBe(true);
// workspace-augmented variant of the same bucket still matches
expect(matcher(['message:list', context, 1, 'workspace-1'])).toBe(true);
expect(matcher(['message:list', { agentId: 'wt-agent', topicId: 'other' }, 1])).toBe(false);
expect(matcher(['topic:list', 'container', {}])).toBe(false);
});
it('skips write-through for the useFetchMessages onData sync path', async () => {
const { result } = renderHook(() => useChatStore());
await act(async () => {
result.current.replaceMessages([{ id: 'm2', role: 'user', content: 'hi' }] as any, {
action: 'useFetchMessages',
context: { agentId: 'wt-agent-2', topicId: 'wt-topic-2' },
});
});
expect(mutate).not.toHaveBeenCalled();
});
it('skips write-through while the context is streaming', async () => {
const { result } = renderHook(() => useChatStore());
const context = { agentId: 'wt-agent-3', topicId: 'wt-topic-3' };
await act(async () => {
// A running AI-runtime op marks the context as streaming.
result.current.startOperation({ type: 'execAgentRuntime', context });
});
(mutate as Mock).mockClear();
await act(async () => {
result.current.replaceMessages([{ id: 'm3', role: 'user', content: 'hi' }] as any, {
context,
});
});
expect(mutate).not.toHaveBeenCalled();
});
it('still seeds the cache when the store-set is a no-op (optimistic echo)', async () => {
const { result } = renderHook(() => useChatStore());
const context = { agentId: 'wt-noop', topicId: 'wt-noop-topic' };
const messages = [{ id: 'm-noop', role: 'user', content: 'hi' }] as any;
const key = messageMapKey(context);
// An optimistic dispatch already applied this exact state to the in-memory
// store, but never touched the SWR cache.
act(() => {
useChatStore.setState({ dbMessagesMap: { [key]: messages } });
});
(mutate as Mock).mockClear();
await act(async () => {
result.current.replaceMessages(messages, {
action: 'optimisticUpdateMessageContent',
context,
});
});
// store-set is a no-op (messagesMap bucket never populated)...
expect(result.current.messagesMap[key]).toBeUndefined();
// ...but the cache is still seeded so a later switch-back is not stale
expect(mutate).toHaveBeenCalledTimes(1);
const [matcher, dataArg, options] = (mutate as Mock).mock.calls[0];
expect(options).toEqual({ revalidate: false });
expect(dataArg).toEqual(messages);
expect(matcher(['message:list', context, 1])).toBe(true);
});
});
describe('Public API with context parameter', () => {
describe('deleteMessage with context', () => {
it('should pass context to optimisticDeleteMessages', async () => {
@@ -6,6 +6,7 @@ import { type SWRResponse } from 'swr';
import { mutate, useClientDataSWRWithSync } from '@/libs/swr';
import { messageKeys } from '@/libs/swr/keys';
import { messageService } from '@/services/message';
import { operationSelectors } from '@/store/chat/slices/operation/selectors';
import { type ChatStore } from '@/store/chat/store';
import { type StoreSetter } from '@/store/types';
@@ -97,6 +98,18 @@ export class MessageQueryActionImpl {
// Get raw messages from dbMessagesMap and apply reducer
const nextDbMap = { ...this.#get().dbMessagesMap, [messagesKey]: reconciled };
// Write through BEFORE the equality early-return below. Optimistic flows
// (optimisticUpdateMessageContent / optimisticDeleteMessage[s]) call
// `internal_dispatchMessage` first — which already applies the mutation to
// `dbMessagesMap` WITHOUT touching the SWR cache — and then
// `replaceMessages(result.messages)`. When the server echo equals the
// already-applied in-memory state, the `isEqual` return fires and the
// store-set is correctly skipped; but the SWR/IndexedDB cache was never
// updated by the dispatch, so a later remount would hydrate the
// pre-mutation snapshot (stale content / deleted rows). Seeding here keeps
// the cache correct even on a store no-op.
this.#writeThroughMessageCache(ctx, messagesKey, reconciled, params?.action);
if (isEqual(nextDbMap, this.#get().dbMessagesMap)) return;
// Parse messages using conversation-flow
@@ -114,6 +127,51 @@ export class MessageQueryActionImpl {
);
};
/**
* Write the settled in-memory messages back into the `message:list` SWR cache
* (and, transitively, the persisted IndexedDB tier) for this exact bucket.
*
* Why: message mutations otherwise only touch the in-memory store, so the SWR
* cache stays stale until a network refetch. Because the Conversation store is
* recreated on every topic/session switch and re-hydrates from this cache, a
* stale cache is what forces a refetch on every switch. Keeping the cache in
* sync here lets a switch-back hydrate from a FRESH cache.
*
* Called even when the `replaceMessages` store-set is a no-op (see caller),
* because an optimistic dispatch may have already applied this exact state to
* the store while leaving the cache stale.
*
* Skipped in two cases:
* - `useFetchMessages` onData — SWR already holds that exact value, so
* re-writing it would double the IndexedDB persist on every fetch.
* - while the context is streaming — `internal_dispatchMessage` bridges every
* token here via `onMessagesChange`, and a write-through per token would
* thrash. `agent_runtime_end` clears the running flag *before* its final
* `replaceMessages`, so the settled snapshot still writes through.
*/
#writeThroughMessageCache = (
ctx: MessageMapKeyInput,
messagesKey: string,
messages: UIChatMessage[],
action?: string,
): void => {
if (action === 'useFetchMessages') return;
if (operationSelectors.isAgentRuntimeRunningByContext(ctx)(this.#get())) return;
// Match every `message:list` entry whose context resolves to the same bucket
// (any page-size / version / workspace-augmented variant). `revalidate: false`
// seeds the cache without firing a network request.
void mutate(
(key) => {
if (!Array.isArray(key) || key[0] !== messageKeys.list.root) return false;
const keyCtx = key[1] as ConversationContext | undefined;
return !!keyCtx && messageMapKey(keyCtx) === messagesKey;
},
messages,
{ revalidate: false },
);
};
useFetchMessages = (
context: ConversationContext,
options?: {