From 55451ee75d78fc588720400a652bae2abccd1b20 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Sun, 12 Jul 2026 13:54:32 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=89=20perf:=20start=20message=20querie?= =?UTF-8?q?s=20before=20file=20map=20loads=20(#14188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../Agents/tests/AgentDetail.spec.tsx | 12 +- client/src/components/Chat/ChatView.tsx | 2 +- .../Chat/Messages/ui/MessageRender.tsx | 20 +- .../ui/__tests__/MessageRender.test.ts | 42 +++ .../Messages/__tests__/queries.test.ts | 243 +++++++++++++++++- client/src/data-provider/Messages/queries.ts | 29 ++- .../useChatFunctions.regenerate.spec.tsx | 92 ++++++- client/src/hooks/Chat/useChatFunctions.ts | 11 +- client/src/hooks/Chat/useChatHelpers.ts | 16 +- .../Conversations/useNavigateToConvo.tsx | 6 +- client/src/utils/__tests__/messages.test.ts | 18 ++ client/src/utils/messages.ts | 14 +- 12 files changed, 470 insertions(+), 35 deletions(-) create mode 100644 client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts diff --git a/client/src/components/Agents/tests/AgentDetail.spec.tsx b/client/src/components/Agents/tests/AgentDetail.spec.tsx index 0a1afffea7..3b9919b37f 100644 --- a/client/src/components/Agents/tests/AgentDetail.spec.tsx +++ b/client/src/components/Agents/tests/AgentDetail.spec.tsx @@ -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(), }; diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 1c84b93b82..5ab4efb1e9 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -59,7 +59,7 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje }, [fileMap], ), - enabled: !!fileMap, + enabled: !!conversationId && conversationId !== Constants.SEARCH, }, { isStreaming: isSubmitting }, ); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index 3fc1879a5b..7bb08e450b 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -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) ); } diff --git a/client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts b/client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts new file mode 100644 index 0000000000..e9aa5c944b --- /dev/null +++ b/client/src/components/Chat/Messages/ui/__tests__/MessageRender.test.ts @@ -0,0 +1,42 @@ +import type { TFile } from 'librechat-data-provider'; +import { areMessageFilesEqual } from '../MessageRender'; + +const file = (overrides: Partial = {}): 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); + }); +}); diff --git a/client/src/data-provider/Messages/__tests__/queries.test.ts b/client/src/data-provider/Messages/__tests__/queries.test.ts index 393151a76f..63c3b086c6 100644 --- a/client/src/data-provider/Messages/__tests__/queries.test.ts +++ b/client/src/data-provider/Messages/__tests__/queries.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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(); + 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(); + 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(); + 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 = [ diff --git a/client/src/data-provider/Messages/queries.ts b/client/src/data-provider/Messages/queries.ts index 462bc6c5f2..248a9776f0 100644 --- a/client/src/data-provider/Messages/queries.ts +++ b/client/src/data-provider/Messages/queries.ts @@ -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 = ( return useQuery( [QueryKeys.messages, id], async () => { + const queryKey = [QueryKeys.messages, id]; + const messagesAtRequestStart = queryClient.getQueryData(queryKey); + + if (id === Constants.NEW_CONVO) { + return messagesAtRequestStart ?? []; + } + let result: t.TMessage[]; try { result = await dataService.getMessagesByConvoId(id); } catch (error) { - const currentMessages = queryClient.getQueryData([QueryKeys.messages, id]); + const currentMessages = queryClient.getQueryData(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 = ( throw error; } - const currentMessages = queryClient.getQueryData([QueryKeys.messages, id]); + const currentMessages = queryClient.getQueryData(queryKey); + if ( + messagesAtRequestStart != null && + currentMessages != null && + currentMessages !== messagesAtRequestStart + ) { + return currentMessages; + } + const stableMessages = getStableMessages({ pathname: location.pathname, result, diff --git a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx index ab5112f0e6..c41a20a3d3 100644 --- a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx @@ -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; + 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(); diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 50578ac428..6fec07443b 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -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; setFiles?: SetterOrUpdater>; @@ -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({ diff --git a/client/src/hooks/Chat/useChatHelpers.ts b/client/src/hooks/Chat/useChatHelpers.ts index cc095f85b6..70bbbbc262 100644 --- a/client/src/hooks/Chat/useChatHelpers.ts +++ b/client/src/hooks/Chat/useChatHelpers.ts @@ -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([QueryKeys.messages, queryParam]); - }, [queryParam, queryClient]); + const getMessages = useCallback( + (targetConversationId?: string | null) => { + return queryClient.getQueryData([ + QueryKeys.messages, + targetConversationId ?? queryParam, + ]); + }, + [queryParam, queryClient], + ); /* Conversation */ // const setActiveConvos = useSetRecoilState(store.activeConversations); diff --git a/client/src/hooks/Conversations/useNavigateToConvo.tsx b/client/src/hooks/Conversations/useNavigateToConvo.tsx index 390f5b77d7..4450991965 100644 --- a/client/src/hooks/Conversations/useNavigateToConvo.tsx +++ b/client/src/hooks/Conversations/useNavigateToConvo.tsx @@ -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]); diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts index b919441348..b2419c291e 100644 --- a/client/src/utils/__tests__/messages.test.ts +++ b/client/src/utils/__tests__/messages.test.ts @@ -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 => ...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 }); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 0c756e7c37..f768dc652c 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -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([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([QueryKeys.messages, Constants.NEW_CONVO], []); + queryClient.removeQueries([QueryKeys.messages, convoId], { exact: true }); } + + queryClient.setQueryData([QueryKeys.messages, Constants.NEW_CONVO], []); }; /** Returns a 1-based message number, or null if depth is absent or invalid. */