📉 perf: start message queries before file map loads (#14188)

* feat(client): remove chat message waterfalls

* fix(client): guard sends during conversation detail loads

* fix(client): observe new chat message cache

* fix(client): preserve streaming messages during prefetch

* fix(client): keep pending regeneration during prefetch

* fix(client): refresh stale pending chat tails

* fix(client): skip message prefetch during active streams

* fix(client): clear observed message caches

* fix(client): support mocked message cache clients

* fix(client): preserve replacement pending tails during message prefetch

* perf(client): start message queries before file map loads
This commit is contained in:
Ravi Kumar L 2026-07-12 13:54:32 +02:00 committed by GitHub
parent 329ed48246
commit 55451ee75d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 470 additions and 35 deletions

View file

@ -1,14 +1,12 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RecoilRoot } from 'recoil';
import type t from 'librechat-data-provider';
import { render, screen, waitFor } from '@testing-library/react';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import AgentDetail from '../AgentDetail';
// Mock dependencies
@ -148,6 +146,7 @@ describe('AgentDetail', () => {
(useQueryClient as jest.Mock).mockReturnValue({
getQueryData: jest.fn(),
setQueryData: jest.fn(),
removeQueries: jest.fn(),
invalidateQueries: jest.fn(),
});
@ -232,6 +231,7 @@ describe('AgentDetail', () => {
const mockQueryClient = {
getQueryData: jest.fn().mockReturnValue(null),
setQueryData: jest.fn(),
removeQueries: jest.fn(),
invalidateQueries: jest.fn(),
};

View file

@ -59,7 +59,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
},
[fileMap],
),
enabled: !!fileMap,
enabled: !!conversationId && conversationId !== Constants.SEARCH,
},
{ isStreaming: isSubmitting },
);

View file

@ -31,6 +31,24 @@ type MessageRenderProps = {
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
>;
export function areMessageFilesEqual(
prevFiles: TMessage['files'],
nextFiles: TMessage['files'],
): boolean {
if (prevFiles === nextFiles) {
return true;
}
const prevLength = prevFiles?.length ?? 0;
const nextLength = nextFiles?.length ?? 0;
if (prevLength !== nextLength) {
return false;
}
if (prevLength === 0) {
return true;
}
return prevFiles?.every((file, index) => file === nextFiles?.[index]) ?? true;
}
/**
* Custom comparator for React.memo: compares `message` by key fields instead of reference
* because `buildTree` creates new message objects on every streaming update for ALL messages,
@ -82,7 +100,7 @@ function areMessageRenderPropsEqual(prev: MessageRenderProps, next: MessageRende
prevMsg.endpoint === nextMsg.endpoint &&
prevMsg.iconURL === nextMsg.iconURL &&
prevMsg.feedback?.rating === nextMsg.feedback?.rating &&
(prevMsg.files?.length ?? 0) === (nextMsg.files?.length ?? 0) &&
areMessageFilesEqual(prevMsg.files, nextMsg.files) &&
(prevMsg.quotes?.length ?? 0) === (nextMsg.quotes?.length ?? 0)
);
}

View file

@ -0,0 +1,42 @@
import type { TFile } from 'librechat-data-provider';
import { areMessageFilesEqual } from '../MessageRender';
const file = (overrides: Partial<TFile> = {}): TFile =>
({
file_id: 'file-1',
filename: 'sample.pdf',
filepath: '/uploads/sample.pdf',
type: 'application/pdf',
bytes: 100,
embedded: false,
object: 'file',
usage: 1,
user: 'user-1',
...overrides,
}) as TFile;
describe('areMessageFilesEqual', () => {
it('detects when a raw message file is replaced by its hydrated file-map entry', () => {
const rawFile = file({ filename: 'raw.pdf', preview: undefined });
const hydratedFile = file({ filename: 'hydrated.pdf', preview: '/previews/sample.png' });
expect(areMessageFilesEqual([rawFile], [hydratedFile])).toBe(false);
});
it('keeps equivalent file entries memoized when buildTree creates a new array', () => {
const hydratedFile = file({ preview: '/previews/sample.png' });
expect(areMessageFilesEqual([hydratedFile], [hydratedFile])).toBe(true);
});
it('detects attachment additions and removals', () => {
const hydratedFile = file();
expect(areMessageFilesEqual([], [hydratedFile])).toBe(false);
expect(areMessageFilesEqual([hydratedFile], [])).toBe(false);
});
it('treats absent and empty file lists as equivalent', () => {
expect(areMessageFilesEqual(undefined, [])).toBe(true);
});
});

View file

@ -1,16 +1,16 @@
import { createElement } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type { ReactNode } from 'react';
import { Constants, QueryKeys, dataService } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import { logger } from '~/utils';
import type { ReactNode } from 'react';
import {
getStableMessages,
shouldPreserveMessagesOnNotFound,
useGetMessagesByConvoId,
} from '../queries';
import { clearMessagesCache, logger } from '~/utils';
jest.mock('librechat-data-provider', () => {
const actual = jest.requireActual('librechat-data-provider');
@ -57,6 +57,16 @@ function createWrapper(queryClient: QueryClient, initialEntry: string) {
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
afterEach(() => {
jest.clearAllMocks();
});
@ -307,6 +317,231 @@ describe('shouldPreserveMessagesOnNotFound', () => {
});
describe('useGetMessagesByConvoId', () => {
it('observes optimistic new-chat messages without requesting them from the API', async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const optimisticMessages = [message({ conversationId: Constants.NEW_CONVO as string })];
const { result, unmount } = renderHook(() => useGetMessagesByConvoId(Constants.NEW_CONVO), {
wrapper: createWrapper(queryClient, '/c/new'),
});
await waitFor(() => {
expect(result.current.data).toEqual([]);
});
expect(dataService.getMessagesByConvoId).not.toHaveBeenCalled();
act(() => {
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], optimisticMessages);
});
await waitFor(() => {
expect(result.current.data).toEqual(optimisticMessages);
});
await act(async () => {
await result.current.refetch();
});
expect(result.current.data).toEqual(optimisticMessages);
expect(dataService.getMessagesByConvoId).not.toHaveBeenCalled();
unmount();
});
it('loads server history after a cleared empty cache is removed', async () => {
const conversationId = 'convo-id';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const serverMessages = [message({ messageId: 'persisted-1' })];
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
typeof dataService.getMessagesByConvoId
>;
mockGetMessagesByConvoId.mockResolvedValue(serverMessages);
queryClient.setQueryData([QueryKeys.messages, conversationId], []);
clearMessagesCache(queryClient, conversationId);
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toBeUndefined();
const { result, unmount } = renderHook(() => useGetMessagesByConvoId(conversationId), {
wrapper: createWrapper(queryClient, `/c/${conversationId}`),
});
await waitFor(() => {
expect(result.current.data).toEqual(serverMessages);
});
expect(mockGetMessagesByConvoId).toHaveBeenCalledTimes(1);
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
unmount();
});
it('does not discard server history when an empty cache changes during the initial request', async () => {
const conversationId = 'convo-id';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const request = deferred<TMessage[]>();
const serverMessages = [message({ messageId: 'persisted-1' })];
const interveningMessages = [
message({ messageId: 'intervening-user', createdAt: undefined, updatedAt: undefined }),
];
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
typeof dataService.getMessagesByConvoId
>;
mockGetMessagesByConvoId.mockReturnValue(request.promise);
const { result, unmount } = renderHook(() => useGetMessagesByConvoId(conversationId), {
wrapper: createWrapper(queryClient, `/c/${conversationId}`),
});
await waitFor(() => {
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
});
act(() => {
queryClient.setQueryData([QueryKeys.messages, conversationId], interveningMessages);
});
await act(async () => {
request.resolve(serverMessages);
await request.promise;
});
await waitFor(() => {
expect(result.current.data).toEqual(serverMessages);
});
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(serverMessages);
unmount();
});
it('keeps a concurrent cache write during a warm refetch', async () => {
const conversationId = 'convo-id';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const request = deferred<TMessage[]>();
const cachedMessages = [message({ messageId: 'persisted-1' })];
const staleServerMessages = [message({ messageId: 'persisted-1', text: 'stale' })];
const concurrentMessages = [
...cachedMessages,
message({ messageId: 'new-user', createdAt: undefined, updatedAt: undefined }),
];
queryClient.setQueryData([QueryKeys.messages, conversationId], cachedMessages);
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
typeof dataService.getMessagesByConvoId
>;
mockGetMessagesByConvoId.mockReturnValue(request.promise);
const { result, unmount } = renderHook(
() => useGetMessagesByConvoId(conversationId, { enabled: false }),
{ wrapper: createWrapper(queryClient, `/c/${conversationId}`) },
);
const refetchPromise = result.current.refetch();
await waitFor(() => {
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
});
act(() => {
queryClient.setQueryData([QueryKeys.messages, conversationId], concurrentMessages);
});
let refetchData: TMessage[] | undefined;
await act(async () => {
request.resolve(staleServerMessages);
refetchData = (await refetchPromise).data;
});
expect(refetchData).toEqual(concurrentMessages);
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(
concurrentMessages,
);
unmount();
});
it('applies fresh server data when a warm cache is unchanged during refetch', async () => {
const conversationId = 'convo-id';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const cachedMessages = [message({ messageId: 'persisted-1', text: 'cached' })];
const freshServerMessages = [message({ messageId: 'persisted-1', text: 'fresh' })];
queryClient.setQueryData([QueryKeys.messages, conversationId], cachedMessages);
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
typeof dataService.getMessagesByConvoId
>;
mockGetMessagesByConvoId.mockResolvedValue(freshServerMessages);
const { result, unmount } = renderHook(
() => useGetMessagesByConvoId(conversationId, { enabled: false }),
{ wrapper: createWrapper(queryClient, `/c/${conversationId}`) },
);
let refetchData: TMessage[] | undefined;
await act(async () => {
refetchData = (await result.current.refetch()).data;
});
expect(refetchData).toEqual(freshServerMessages);
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(
freshServerMessages,
);
unmount();
});
it('keeps a concurrent warm cache write when the refetch fails', async () => {
const conversationId = 'convo-id';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const request = deferred<TMessage[]>();
const cachedMessages = [message({ messageId: 'persisted-1' })];
const concurrentMessages = [
...cachedMessages,
message({ messageId: 'new-user', createdAt: undefined, updatedAt: undefined }),
];
queryClient.setQueryData([QueryKeys.messages, conversationId], cachedMessages);
const mockGetMessagesByConvoId = dataService.getMessagesByConvoId as jest.MockedFunction<
typeof dataService.getMessagesByConvoId
>;
mockGetMessagesByConvoId.mockReturnValue(request.promise);
const { result, unmount } = renderHook(
() => useGetMessagesByConvoId(conversationId, { enabled: false }),
{ wrapper: createWrapper(queryClient, `/c/${conversationId}`) },
);
const refetchPromise = result.current.refetch();
await waitFor(() => {
expect(mockGetMessagesByConvoId).toHaveBeenCalledWith(conversationId);
});
act(() => {
queryClient.setQueryData([QueryKeys.messages, conversationId], concurrentMessages);
request.reject({ status: 500 });
});
let refetchData: TMessage[] | undefined;
await act(async () => {
refetchData = (await refetchPromise).data;
});
expect(refetchData).toEqual(concurrentMessages);
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toEqual(
concurrentMessages,
);
expect(result.current.isError).toBe(false);
unmount();
});
it('keeps cache during submitting cleanup when active job cache still marks the stream active', async () => {
const conversationId = 'convo-id';
const currentMessages = [

View file

@ -1,8 +1,8 @@
import { useLayoutEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { UseQueryOptions, QueryObserverResult, QueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys, dataService } from 'librechat-data-provider';
import type { UseQueryOptions, QueryObserverResult, QueryClient } from '@tanstack/react-query';
import type * as t from 'librechat-data-provider';
import { isNotFoundError, logger } from '~/utils';
@ -99,11 +99,26 @@ export const useGetMessagesByConvoId = <TData = t.TMessage[]>(
return useQuery<t.TMessage[], unknown, TData>(
[QueryKeys.messages, id],
async () => {
const queryKey = [QueryKeys.messages, id];
const messagesAtRequestStart = queryClient.getQueryData<t.TMessage[]>(queryKey);
if (id === Constants.NEW_CONVO) {
return messagesAtRequestStart ?? [];
}
let result: t.TMessage[];
try {
result = await dataService.getMessagesByConvoId(id);
} catch (error) {
const currentMessages = queryClient.getQueryData<t.TMessage[]>([QueryKeys.messages, id]);
const currentMessages = queryClient.getQueryData<t.TMessage[]>(queryKey);
if (
messagesAtRequestStart != null &&
currentMessages != null &&
currentMessages !== messagesAtRequestStart
) {
return currentMessages;
}
const hasLiveStream = isStreamingRef.current || hasActiveJob(queryClient, id);
if (
currentMessages &&
@ -125,7 +140,15 @@ export const useGetMessagesByConvoId = <TData = t.TMessage[]>(
throw error;
}
const currentMessages = queryClient.getQueryData<t.TMessage[]>([QueryKeys.messages, id]);
const currentMessages = queryClient.getQueryData<t.TMessage[]>(queryKey);
if (
messagesAtRequestStart != null &&
currentMessages != null &&
currentMessages !== messagesAtRequestStart
) {
return currentMessages;
}
const stableMessages = getStableMessages({
pathname: location.pathname,
result,

View file

@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react';
import { EModelEndpoint } from 'librechat-data-provider';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type { TConversation, TMessage, TSubmission } from 'librechat-data-provider';
import useChatFunctions from '../useChatFunctions';
@ -11,6 +11,7 @@ const mockSetFilesToDelete = jest.fn();
const mockGetSender = jest.fn(() => 'Assistant');
const mockGetExpiry = jest.fn(() => 'expiry-key');
const mockGetQueryData = jest.fn(() => ({}));
const mockLoggerWarn = jest.fn();
jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate,
@ -49,6 +50,7 @@ jest.mock('~/store', () => ({
isSubmittingFamily: () => 'isSubmitting',
showStopButtonByIndex: () => 'showStopButton',
pendingManualSkillsByConvoId: () => 'pendingManualSkills',
pendingQuotesByConvoId: () => 'pendingQuotes',
messagesSiblingIdxFamily: () => 'messagesSiblingIdx',
},
useGetEphemeralAgent: () => mockGetEphemeralAgent,
@ -57,6 +59,7 @@ jest.mock('~/utils', () => ({
logger: {
log: jest.fn(),
dir: jest.fn(),
warn: (...args: unknown[]) => mockLoggerWarn(...args),
},
createDualMessageContent: jest.fn(() => []),
getRouteChatProjectId: jest.fn(() => null),
@ -82,6 +85,93 @@ const assistantMessage = (messageId: string, parentMessageId: string) =>
text: messageId,
}) as TMessage;
const conversation = (conversationId: string) =>
({
conversationId,
endpoint: EModelEndpoint.agents,
model: 'gpt-4o',
agent_id: 'agent-1',
}) as TConversation;
function renderAsk(messages: TMessage[] | undefined, conversationId = 'conversation-1') {
const setMessages = jest.fn();
const setSubmission = jest.fn();
const getMessages = jest.fn(() => messages);
const hook = renderHook(() =>
useChatFunctions({
isSubmitting: false,
latestMessage: messages?.at(-1) ?? null,
conversation: conversation(conversationId),
getMessages,
setMessages,
setSubmission,
}),
);
return { ...hook, getMessages, setMessages, setSubmission };
}
describe('useChatFunctions ask', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetQueryData.mockReturnValue({});
});
it('refuses to send to an existing conversation before its history loads', () => {
const { result, getMessages, setMessages, setSubmission } = renderAsk(undefined);
let askResult: ReturnType<typeof result.current.ask>;
act(() => {
askResult = result.current.ask({ text: 'Hello', conversationId: 'conversation-1' });
});
expect(askResult!).toBe(false);
expect(getMessages).toHaveBeenCalledWith('conversation-1');
expect(setMessages).not.toHaveBeenCalled();
expect(setSubmission).not.toHaveBeenCalled();
expect(mockLoggerWarn).toHaveBeenCalledWith(
'[useChatFunctions] Refusing to send before existing conversation history loads',
);
});
it('allows an existing conversation whose loaded history is empty', () => {
const { result, setMessages, setSubmission } = renderAsk([]);
act(() => {
result.current.ask({ text: 'Hello', conversationId: 'conversation-1' });
});
expect(setMessages).toHaveBeenCalled();
expect(setSubmission).toHaveBeenCalled();
});
it('allows a new conversation before its message cache exists', () => {
const newConversationId = Constants.NEW_CONVO as string;
const { result, setMessages, setSubmission } = renderAsk(undefined, newConversationId);
act(() => {
result.current.ask({ text: 'Hello', conversationId: newConversationId });
});
expect(setMessages).toHaveBeenCalled();
expect(setSubmission).toHaveBeenCalled();
});
it('allows explicit override messages before the cache exists', () => {
const { result, setMessages, setSubmission } = renderAsk(undefined);
act(() => {
result.current.ask(
{ text: 'Hello', conversationId: 'conversation-1' },
{ overrideMessages: [] },
);
});
expect(setMessages).toHaveBeenCalled();
expect(setSubmission).toHaveBeenCalled();
});
});
describe('useChatFunctions regenerate', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -197,7 +197,7 @@ export default function useChatFunctions({
paramId?: string | undefined;
conversation: TConversation | null;
latestMessage: TMessage | null;
getMessages: () => TMessage[] | undefined;
getMessages: (conversationId?: string | null) => TMessage[] | undefined;
setMessages: (messages: TMessage[]) => void;
files?: Map<string, ExtendedFile>;
setFiles?: SetterOrUpdater<Map<string, ExtendedFile>>;
@ -302,6 +302,13 @@ export default function useChatFunctions({
return;
}
const cachedMessages = getMessages(conversationId);
const isExistingConversation = conversationId != null && conversationId !== Constants.NEW_CONVO;
if (isExistingConversation && overrideMessages == null && cachedMessages == null) {
logger.warn('[useChatFunctions] Refusing to send before existing conversation history loads');
return false;
}
if (isContinued && !latestMessage) {
console.error('cannot continue AI message without latestMessage!');
return;
@ -357,7 +364,7 @@ export default function useChatFunctions({
}
const isEditOrContinue = isEdited || isContinued;
let currentMessages: TMessage[] = overrideMessages ?? getMessages() ?? [];
let currentMessages: TMessage[] = overrideMessages ?? cachedMessages ?? [];
if (conversation?.promptPrefix) {
conversation.promptPrefix = replaceSpecialVars({

View file

@ -1,13 +1,13 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { QueryKeys, isAssistantsEndpoint } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { QueryKeys, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { ActiveJobsResponse } from '~/data-provider';
import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
import useChatFunctions from '~/hooks/Chat/useChatFunctions';
import { useAbortStreamMutation } from '~/data-provider';
import useNewConvo from '~/hooks/useNewConvo';
import { useLatestMessage, useLatestMessageId } from '~/hooks/Messages/useLatestMessage';
import { getMessageCacheIds } from './cache';
import store from '~/store';
@ -51,9 +51,15 @@ export default function useChatHelpers(index = 0, paramId?: string) {
[queryParam, queryClient, conversationId],
);
const getMessages = useCallback(() => {
return queryClient.getQueryData<TMessage[]>([QueryKeys.messages, queryParam]);
}, [queryParam, queryClient]);
const getMessages = useCallback(
(targetConversationId?: string | null) => {
return queryClient.getQueryData<TMessage[]>([
QueryKeys.messages,
targetConversationId ?? queryParam,
]);
},
[queryParam, queryClient],
);
/* Conversation */
// const setActiveConvos = useSetRecoilState(store.activeConversations);

View file

@ -121,10 +121,8 @@ const useNavigateToConvo = (index = 0) => {
if (convo.conversationId !== Constants.NEW_CONVO && convo.conversationId) {
/**
* Remove (not just invalidate) the target's messages so a freshly-mounted
* ChatView refetches them. A prior `clearMessagesCache` can leave this
* conversation cached as `[]`, which the messages query's `refetchOnMount: false`
* would treat as valid leaving the chat stuck on an empty cache with no
* request when navigating in from a non-chat route (e.g. /projects).
* ChatView refetches them even with `refetchOnMount: false`, including when
* navigating in from a non-chat route (e.g. /projects).
*/
queryClient.removeQueries([QueryKeys.messages, convo.conversationId]);
queryClient.invalidateQueries([QueryKeys.conversation, convo.conversationId]);

View file

@ -1,6 +1,9 @@
import { QueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { LocalizeFunction } from '~/common';
import {
clearMessagesCache,
isValidTimestamp,
getMessageAriaLabel,
getMessageTimestamp,
@ -32,6 +35,21 @@ const makeMessage = (overrides: Partial<TMessage> = {}): TMessage =>
...overrides,
}) as TMessage;
describe('clearMessagesCache', () => {
it('removes existing-conversation history while resetting the new-conversation cache', () => {
const queryClient = new QueryClient();
const conversationId = 'conversation-1';
const messages = [makeMessage({ conversationId })];
queryClient.setQueryData([QueryKeys.messages, conversationId], messages);
queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], messages);
clearMessagesCache(queryClient, conversationId);
expect(queryClient.getQueryData([QueryKeys.messages, conversationId])).toBeUndefined();
expect(queryClient.getQueryData([QueryKeys.messages, Constants.NEW_CONVO])).toEqual([]);
});
});
describe('getMessageAriaLabel', () => {
it('returns "Message N" when depth is present and valid', () => {
const msg = makeMessage({ depth: 2 });

View file

@ -292,9 +292,8 @@ export const scrollToEnd = (callback?: () => void) => {
};
/**
* Clears messages for both the specified conversation ID and the NEW_CONVO query key.
* This ensures that messages are properly cleared in all contexts, preventing stale data
* from persisting in the NEW_CONVO cache.
* Removes an existing conversation's message query so reopening it starts cold, and resets the
* NEW_CONVO query to an empty cache for immediate optimistic messages.
*
* @param queryClient - The React Query client instance
* @param conversationId - The conversation ID to clear messages for
@ -305,13 +304,12 @@ export const clearMessagesCache = (
): void => {
const convoId = conversationId ?? Constants.NEW_CONVO;
// Clear messages for the current conversation
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, convoId], []);
// Also clear NEW_CONVO messages if we're not already on NEW_CONVO
// An absent existing-conversation cache means its history must load before sending.
if (convoId !== Constants.NEW_CONVO) {
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
queryClient.removeQueries([QueryKeys.messages, convoId], { exact: true });
}
queryClient.setQueryData<TMessage[]>([QueryKeys.messages, Constants.NEW_CONVO], []);
};
/** Returns a 1-based message number, or null if depth is absent or invalid. */