diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx index 955810b20b..e5daace2b7 100644 --- a/client/src/components/Chat/Messages/SearchMessage.tsx +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -1,6 +1,7 @@ -import { useMemo } from 'react'; +import { memo, useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useRecoilValue } from 'recoil'; +import type { TMessage } from 'librechat-data-provider'; import type { TMessageProps, TMessageIcon } from '~/common'; import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons'; import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; @@ -39,7 +40,59 @@ const MessageBody = ({ message, messageLabel, fontSize }) => ( ); -export default function SearchMessage({ message }: Pick) { +function searchFilesEqual(prev?: TMessage['files'], next?: TMessage['files']) { + if (prev === next) { + return true; + } + const prevLen = prev?.length ?? 0; + const nextLen = next?.length ?? 0; + if (prevLen !== nextLen) { + return false; + } + return prev?.every((file, index) => file.file_id === next?.[index]?.file_id) ?? true; +} + +/** + * Field-level comparator for `memo(SearchMessage)`. The virtualized `rowRenderer` + * closure and the file-remap `useMemo` in the Search route can hand a fresh + * `message` object with identical content on every parent render, so a shallow + * compare would defeat the memo — compare only the fields that drive the row. + */ +export function areSearchMessagePropsEqual( + prev: Pick, + next: Pick, +): boolean { + const a = prev.message; + const b = next.message; + if (a === b) { + return true; + } + if (!a || !b) { + return a === b; + } + return ( + a.messageId === b.messageId && + a.text === b.text && + a.content === b.content && + a.createdAt === b.createdAt && + /** Timestamp falls back to `clientTimestamp` when `createdAt` is absent. */ + a.clientTimestamp === b.clientTimestamp && + a.isCreatedByUser === b.isCreatedByUser && + a.sender === b.sender && + a.model === b.model && + a.endpoint === b.endpoint && + a.iconURL === b.iconURL && + /** `SearchContent` renders an incomplete-response notice on `unfinished`. */ + a.unfinished === b.unfinished && + /** `SearchButtons` renders `title` and navigates by `conversationId`, so a + * rename/refetch that leaves the text and id intact must still re-render. */ + a.title === b.title && + a.conversationId === b.conversationId && + searchFilesEqual(a.files, b.files) + ); +} + +function SearchMessage({ message }: Pick) { const fontSize = useAtomValue(fontSizeAtom); const UsernameDisplay = useRecoilValue(store.UsernameDisplay); const { user } = useAuthContext(); @@ -86,3 +139,5 @@ export default function SearchMessage({ message }: Pick ); } + +export default memo(SearchMessage, areSearchMessagePropsEqual); diff --git a/client/src/components/Chat/Messages/__tests__/SearchMessage.comparator.spec.ts b/client/src/components/Chat/Messages/__tests__/SearchMessage.comparator.spec.ts new file mode 100644 index 0000000000..107d1021a4 --- /dev/null +++ b/client/src/components/Chat/Messages/__tests__/SearchMessage.comparator.spec.ts @@ -0,0 +1,77 @@ +import type { TMessage } from 'librechat-data-provider'; +import { areSearchMessagePropsEqual } from '../SearchMessage'; + +const msg = (overrides: Partial = {}): TMessage => + ({ + messageId: 'm1', + text: 'hello zephyrine', + content: undefined, + createdAt: '2026-07-20T00:00:00.000Z', + isCreatedByUser: false, + sender: 'GPT-3.5', + model: 'gpt-3.5-turbo', + endpoint: 'openAI', + iconURL: '', + files: undefined, + ...overrides, + }) as TMessage; + +describe('areSearchMessagePropsEqual', () => { + it('is true for the same reference', () => { + const m = msg(); + expect(areSearchMessagePropsEqual({ message: m }, { message: m })).toBe(true); + }); + + it('is true for distinct objects with identical rendered fields (defeats a fresh useMemo remap)', () => { + expect(areSearchMessagePropsEqual({ message: msg() }, { message: msg() })).toBe(true); + }); + + it('is false when the text changes', () => { + expect( + areSearchMessagePropsEqual({ message: msg() }, { message: msg({ text: 'different' }) }), + ).toBe(false); + }); + + it('is false when the messageId changes', () => { + expect( + areSearchMessagePropsEqual({ message: msg() }, { message: msg({ messageId: 'm2' }) }), + ).toBe(false); + }); + + it('compares files by file_id, not object identity', () => { + const a = msg({ files: [{ file_id: 'f1' }] as TMessage['files'] }); + const b = msg({ files: [{ file_id: 'f1' }] as TMessage['files'] }); + expect(areSearchMessagePropsEqual({ message: a }, { message: b })).toBe(true); + const c = msg({ files: [{ file_id: 'f2' }] as TMessage['files'] }); + expect(areSearchMessagePropsEqual({ message: a }, { message: c })).toBe(false); + }); + + it('is false when the conversation title changes (rename), even if text/id match', () => { + expect( + areSearchMessagePropsEqual({ message: msg() }, { message: msg({ title: 'Renamed' }) }), + ).toBe(false); + }); + + it('is false when the navigation target conversationId changes', () => { + expect( + areSearchMessagePropsEqual({ message: msg() }, { message: msg({ conversationId: 'c2' }) }), + ).toBe(false); + }); + + it('is false when the unfinished flag changes (incomplete-response notice)', () => { + expect( + areSearchMessagePropsEqual({ message: msg() }, { message: msg({ unfinished: true }) }), + ).toBe(false); + }); + + it('is false when only clientTimestamp changes (createdAt-less timestamp fallback)', () => { + const a = msg({ createdAt: undefined, clientTimestamp: '2026-07-20T00:00:00.000Z' }); + const b = msg({ createdAt: undefined, clientTimestamp: '2026-07-20T01:00:00.000Z' }); + expect(areSearchMessagePropsEqual({ message: a }, { message: b })).toBe(false); + }); + + it('is false when exactly one message is nullish', () => { + expect(areSearchMessagePropsEqual({ message: undefined }, { message: msg() })).toBe(false); + expect(areSearchMessagePropsEqual({ message: msg() }, { message: undefined })).toBe(false); + }); +}); diff --git a/client/src/routes/Search.tsx b/client/src/routes/Search.tsx index b4db28c651..de6fe54894 100644 --- a/client/src/routes/Search.tsx +++ b/client/src/routes/Search.tsx @@ -1,19 +1,91 @@ -import { useEffect, useMemo } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, type FC } from 'react'; +import { useAtomValue } from 'jotai'; +import throttle from 'lodash/throttle'; import { useRecoilValue } from 'recoil'; import { Spinner, useToastContext } from '@librechat/client'; -import MinimalMessagesWrapper from '~/components/Chat/Messages/MinimalMessages'; -import { useNavScrolling, useLocalize, useAuthContext } from '~/hooks'; +import { List, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; +import type { Index, ListRowProps } from 'react-virtualized'; +import type { TMessage } from 'librechat-data-provider'; +import { useElementSize, useLocalize, useAuthContext } from '~/hooks'; import SearchMessage from '~/components/Chat/Messages/SearchMessage'; import { useMessagesInfiniteQuery } from '~/data-provider'; import { useFileMapContext } from '~/Providers'; +import { fontSizeAtom } from '~/store/fontSize'; +import { cn } from '~/utils'; import store from '~/store'; +type MeasuredCellParent = { + invalidateCellSizeAfterRender?: (cell: { columnIndex: number; rowIndex: number }) => void; + recomputeGridSize?: (cell: { columnIndex: number; rowIndex: number }) => void; +}; + +/** Fixed trailing spacer so the last result clears the bottom gradient/spinner + * overlay instead of sitting underneath it. */ +const FOOTER_HEIGHT = 64; + +/** Virtualized row wrapper that reports its measured height back to the cache. + * A ResizeObserver on the content re-measures when a row later grows or shrinks + * (a tool/code output expands, a late image loads), so the cached height that + * the List now lays out from never goes stale. */ +const MeasuredRow: FC<{ + cache: CellMeasurerCache; + rowKey: string; + parent: MeasuredCellParent; + index: number; + style: React.CSSProperties; + onResize: (index: number) => void; + children: React.ReactNode; +}> = memo(({ cache, rowKey, parent, index, style, onResize, children }) => { + const contentRef = useRef(null); + + useEffect(() => { + const el = contentRef.current; + if (!el || typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver((entries) => { + const height = entries[0]?.contentRect.height ?? 0; + /** Invalidate whenever the content differs from the height the List is + * laying out from — including the first callback, since a cached/fast + * image can already be taller than what CellMeasurer recorded at mount. */ + if (height > 0 && Math.abs(height - cache.getHeight(index, 0)) > 1) { + onResize(index); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, [cache, index, onResize]); + + return ( + + {({ registerChild }) => ( +
} + style={style} + data-testid="search-result-row" + > +
{children}
+
+ )} +
+ ); +}); + +MeasuredRow.displayName = 'SearchMeasuredRow'; + export default function Search() { const localize = useLocalize(); const fileMap = useFileMapContext(); const { showToast } = useToastContext(); const { isAuthenticated } = useAuthContext(); const search = useRecoilValue(store.search); + const fontSize = useAtomValue(fontSizeAtom); const searchQuery = search.debouncedQuery; const { @@ -22,27 +94,21 @@ export default function Search() { isError, fetchNextPage, isFetchingNextPage, - hasNextPage: _hasNextPage, + hasNextPage, + isPreviousData, } = useMessagesInfiniteQuery( - { - search: searchQuery || undefined, - }, - { - enabled: isAuthenticated && !!searchQuery, - staleTime: 30000, - cacheTime: 300000, - }, + { search: searchQuery || undefined }, + { enabled: isAuthenticated && !!searchQuery, staleTime: 30000, cacheTime: 300000 }, ); - const { containerRef } = useNavScrolling({ - nextCursor: searchMessages?.pages[searchMessages.pages.length - 1]?.nextCursor, - setShowLoading: () => ({}), - fetchNextPage: fetchNextPage, - isFetchingNext: isFetchingNextPage, - }); + /** Stale-results window: `isTyping` clears the moment the debounce publishes + * the new `debouncedQuery`, but `keepPreviousData` keeps the OLD pages mounted + * until the new request lands (`isPreviousData`). Both must gate the dimming + * and pagination, or the outgoing results look and page like the new search. */ + const showingStale = search.isTyping || isPreviousData; - const messages = useMemo(() => { - const msgs = + const messages = useMemo( + () => searchMessages?.pages.flatMap((page) => page.messages.map((message) => { if (!message.files || !fileMap) { @@ -53,10 +119,148 @@ export default function Search() { files: message.files.map((file) => fileMap[file.file_id ?? ''] ?? file), }; }), - ) || []; + ) ?? [], + [fileMap, searchMessages?.pages], + ); - return msgs.length === 0 ? null : msgs; - }, [fileMap, searchMessages?.pages]); + /** keyMapper reads a ref so the cache is created once and heights stay keyed + * to messageId (stable across pagination/reorders), not row index. */ + const itemsRef = useRef(messages); + itemsRef.current = messages; + + const listRef = useRef(null); + const { + ref: listContainerRef, + width: listWidth, + height: listHeight, + } = useElementSize(); + + const cache = useMemo( + () => + new CellMeasurerCache({ + fixedWidth: true, + defaultHeight: 140, + keyMapper: (index) => itemsRef.current[index]?.messageId ?? `search-row-${index}`, + }), + [], + ); + + const recompute = useCallback( + (clear: boolean) => { + if (clear) { + cache.clearAll(); + } + listRef.current?.recomputeRowHeights(0); + }, + [cache], + ); + + /** A new query reseeds the list: prior results stay mounted (keepPreviousData) + * so the List keeps its old scrollTop — drop measured heights AND scroll back + * to the top, or the next search can open mid-list and hide the top matches. */ + useEffect(() => { + const frameId = requestAnimationFrame(() => { + recompute(true); + listRef.current?.scrollToPosition(0); + }); + return () => cancelAnimationFrame(frameId); + }, [searchQuery, recompute]); + + /** A font-size change alters every row's height but keeps the user's place. */ + useEffect(() => { + const frameId = requestAnimationFrame(() => recompute(true)); + return () => cancelAnimationFrame(frameId); + }, [fontSize, recompute]); + + /** Appending a page keeps existing measures; any other content change at the + * same row count (a file preview resolving, a refetch) can alter a row's + * rendered height, so drop the stale heights and re-measure. */ + const prevCountRef = useRef(0); + useEffect(() => { + const grew = messages.length > prevCountRef.current; + prevCountRef.current = messages.length; + const frameId = requestAnimationFrame(() => recompute(!grew)); + return () => cancelAnimationFrame(frameId); + }, [messages, recompute]); + + /** fixedWidth cache keys heights by row, not width — re-measure on width change. */ + const measuredWidthRef = useRef(0); + useEffect(() => { + if (listWidth === 0 || listWidth === measuredWidthRef.current) { + return; + } + measuredWidthRef.current = listWidth; + const frameId = requestAnimationFrame(() => recompute(true)); + return () => cancelAnimationFrame(frameId); + }, [listWidth, recompute]); + + /** Row-local size change (tool output expands, image loads): drop that row's + * cached height and recompute from it so the layout below stays correct. */ + const invalidateRowHeight = useCallback( + (index: number) => { + cache.clear(index, 0); + listRef.current?.recomputeRowHeights(index); + }, + [cache], + ); + + /** `trailing: false` so a burst near the bottom can't queue a fetch that fires + * after the guard passed; cancel on query change so a pending page can't land + * on a new search. */ + const throttledFetchNext = useMemo( + () => throttle(() => fetchNextPage(), 500, { leading: true, trailing: false }), + [fetchNextPage], + ); + useEffect(() => () => throttledFetchNext.cancel(), [throttledFetchNext]); + + const handleRowsRendered = useCallback( + ({ stopIndex }: { stopIndex: number }) => { + /** Don't page while the outgoing results are still mounted (typing, or the + * new query is still fetching and previous data is shown). */ + if (showingStale || !hasNextPage || isFetchingNextPage) { + return; + } + if (stopIndex >= messages.length - 8) { + throttledFetchNext(); + } + }, + [showingStale, hasNextPage, isFetchingNextPage, messages.length, throttledFetchNext], + ); + + const rowRenderer = useCallback( + ({ index, key, parent, style }: ListRowProps) => { + const message = messages[index]; + if (!message) { + /** Trailing spacer row (see FOOTER_HEIGHT). */ + return ( +