mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🪟 perf: Virtualize Search Results and Stop the Per-Query Remount (#14352)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* 🪟 perf: Virtualize Search Results and Stop the Per-Query Remount * 🔧 fix: Type-safe globalThis cast in Search route test * 🔑 fix: Stabilize Search Row Keys and Harden Virtualized Result Edges Address Codex review findings on the virtualized search results view: - Key rows by messageId (outer React key + CellMeasurer + cache keyMapper all aligned) so React reconciles by message, not scroll slot. - Compare title and conversationId in areSearchMessagePropsEqual so a rename or refetch that keeps text/id intact still re-renders the row. - Recompute cached heights on same-length content changes (file previews resolving, refetch) and on font-size changes. - Keep the aria-live announcement on the empty-results branch. - Don't paginate the outgoing query while the user is still typing. - Show the spinner during the initial debounce instead of a blank route. * 🧵 fix: Reset Scroll, Remeasure Growth, and Footer-Pad Virtualized Search Address the second Codex round on the virtualized search results view: - Reset the List scroll to the top on a new query (results stay mounted via keepPreviousData, so the List otherwise keeps the previous scrollTop and can open a new search mid-list); a font-size change keeps the user's place. - Re-measure a row when its content later grows/shrinks (tool/code output expands, a late image loads) via a ResizeObserver that clears just that row's cached height and recomputes from it. - Give the load-more throttle trailing:false and cancel it when the query changes, so a queued fetch can't page a stale search. - Add a fixed trailing spacer row so the last result clears the bottom gradient/spinner overlay. * 🫥 fix: Gate Stale Search Results on Refetch State, Not Just Typing Address the third Codex round: `isTyping` clears when the debounce publishes the new query, but `keepPreviousData` keeps the old pages mounted until the new request lands, leaving a window the typing-only guards missed. - Derive `showingStale = isTyping || isPreviousData` and gate both the dimming and pagination on it, so the outgoing results stay dimmed and don't page while the new query is still fetching. - Compare `unfinished` in areSearchMessagePropsEqual so a finish/cancel that changes only that flag re-renders SearchContent's incomplete-response notice. * 📐 fix: Invalidate Row Height Against the Cache and Compare clientTimestamp Address the fourth Codex round on virtualized search: - Compare each ResizeObserver height against the cached row height instead of skipping the first callback, so a cached/fast-loading image that is already taller than CellMeasurer's mount measurement still invalidates the stale height (no more overlap/clipping of following rows). - Compare clientTimestamp in areSearchMessagePropsEqual, since the row timestamp falls back to it when createdAt is absent. * 🕳️ fix: Spinner Over False Nothing-Found for Stale Empty Search Data Address the fifth Codex round: when the previous search had zero matches, keepPreviousData holds those empty pages (isPreviousData, isLoading false) during the new request, so the loading gate missed it and flashed a false "nothing found". Gate the spinner on `showingStale` too, not just isLoading/ isTyping.
This commit is contained in:
parent
8751cc1c5c
commit
af7b2761eb
4 changed files with 623 additions and 55 deletions
|
|
@ -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 }) => (
|
|||
</div>
|
||||
);
|
||||
|
||||
export default function SearchMessage({ message }: Pick<TMessageProps, 'message'>) {
|
||||
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<TMessageProps, 'message'>,
|
||||
next: Pick<TMessageProps, 'message'>,
|
||||
): 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<TMessageProps, 'message'>) {
|
||||
const fontSize = useAtomValue(fontSizeAtom);
|
||||
const UsernameDisplay = useRecoilValue<boolean>(store.UsernameDisplay);
|
||||
const { user } = useAuthContext();
|
||||
|
|
@ -86,3 +139,5 @@ export default function SearchMessage({ message }: Pick<TMessageProps, 'message'
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(SearchMessage, areSearchMessagePropsEqual);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { areSearchMessagePropsEqual } from '../SearchMessage';
|
||||
|
||||
const msg = (overrides: Partial<TMessage> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<HTMLDivElement | null>(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 (
|
||||
<CellMeasurer
|
||||
cache={cache}
|
||||
columnIndex={0}
|
||||
key={rowKey}
|
||||
parent={parent as ListRowProps['parent']}
|
||||
rowIndex={index}
|
||||
>
|
||||
{({ registerChild }) => (
|
||||
<div
|
||||
ref={registerChild as React.LegacyRef<HTMLDivElement>}
|
||||
style={style}
|
||||
data-testid="search-result-row"
|
||||
>
|
||||
<div ref={contentRef}>{children}</div>
|
||||
</div>
|
||||
)}
|
||||
</CellMeasurer>
|
||||
);
|
||||
});
|
||||
|
||||
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<TMessage[]>(messages);
|
||||
itemsRef.current = messages;
|
||||
|
||||
const listRef = useRef<List>(null);
|
||||
const {
|
||||
ref: listContainerRef,
|
||||
width: listWidth,
|
||||
height: listHeight,
|
||||
} = useElementSize<HTMLDivElement>();
|
||||
|
||||
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 (
|
||||
<div key="search-footer" style={style} data-testid="search-footer" aria-hidden="true" />
|
||||
);
|
||||
}
|
||||
/** react-virtualized's `key` is positional; key by messageId so React
|
||||
* reconciles rows by message, not slot — otherwise a scroll reuses a row
|
||||
* instance for a different result and re-parses/reruns its subtree. */
|
||||
const rowKey = message.messageId ?? key;
|
||||
return (
|
||||
<MeasuredRow
|
||||
key={rowKey}
|
||||
cache={cache}
|
||||
rowKey={rowKey}
|
||||
parent={parent as MeasuredCellParent}
|
||||
index={index}
|
||||
style={style}
|
||||
onResize={invalidateRowHeight}
|
||||
>
|
||||
<SearchMessage message={message} />
|
||||
</MeasuredRow>
|
||||
);
|
||||
},
|
||||
[cache, messages, invalidateRowHeight],
|
||||
);
|
||||
|
||||
const getRowHeight = useCallback(
|
||||
({ index }: Index) => (index >= messages.length ? FOOTER_HEIGHT : cache.getHeight(index, 0)),
|
||||
[cache, messages.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isError && searchQuery) {
|
||||
|
|
@ -64,7 +268,7 @@ export default function Search() {
|
|||
}
|
||||
}, [isError, searchQuery, showToast]);
|
||||
|
||||
const resultsCount = messages?.length ?? 0;
|
||||
const resultsCount = messages.length;
|
||||
const resultsAnnouncement = useMemo(() => {
|
||||
if (resultsCount === 0) {
|
||||
return localize('com_ui_nothing_found');
|
||||
|
|
@ -75,44 +279,70 @@ export default function Search() {
|
|||
return localize('com_ui_results_found', { count: resultsCount });
|
||||
}, [resultsCount, localize]);
|
||||
|
||||
const isSearchLoading = search.isTyping || isLoading || isFetchingNextPage;
|
||||
|
||||
if (isSearchLoading) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const loadingSpinner = (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="text-text-primary" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!searchQuery) {
|
||||
return null;
|
||||
/** A fresh query is typed but its debounce hasn't fired yet: show loading
|
||||
* rather than a blank route during that first delay. */
|
||||
return search.query && search.isTyping ? loadingSpinner : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MinimalMessagesWrapper ref={containerRef} className="relative flex h-full pt-4">
|
||||
<div className="sr-only" role="alert" aria-atomic="true">
|
||||
{resultsAnnouncement}
|
||||
</div>
|
||||
{(messages && messages.length === 0) || messages == null ? (
|
||||
const hasResults = resultsCount > 0;
|
||||
|
||||
/** Spinner while there is nothing to show AND we're loading or the current
|
||||
* results are stale — `showingStale` covers the case where the previous
|
||||
* search was empty and `keepPreviousData` holds those empty pages during the
|
||||
* new request, which would otherwise flash a false "nothing found". */
|
||||
if ((isLoading || showingStale) && !hasResults) {
|
||||
return loadingSpinner;
|
||||
}
|
||||
|
||||
if (!hasResults) {
|
||||
return (
|
||||
<>
|
||||
<div className="sr-only" role="alert" aria-atomic="true">
|
||||
{resultsAnnouncement}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="rounded-lg bg-white p-6 text-lg text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
|
||||
{localize('com_ui_nothing_found')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((msg) => (
|
||||
<SearchMessage key={msg.messageId} message={msg} />
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner className="text-text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col bg-white pt-4 dark:bg-gray-800">
|
||||
<div className="sr-only" role="alert" aria-atomic="true">
|
||||
{resultsAnnouncement}
|
||||
</div>
|
||||
<div ref={listContainerRef} className="min-h-0 flex-1">
|
||||
<List
|
||||
ref={listRef}
|
||||
width={listWidth}
|
||||
height={listHeight}
|
||||
deferredMeasurementCache={cache}
|
||||
rowCount={resultsCount + 1}
|
||||
rowHeight={getRowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
onRowsRendered={handleRowsRendered}
|
||||
overscanRowCount={10}
|
||||
aria-label={localize('com_nav_search_placeholder')}
|
||||
className={cn('outline-none', showingStale && 'opacity-70')}
|
||||
style={{ outline: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
{isFetchingNextPage && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 flex justify-center py-4">
|
||||
<Spinner className="text-text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[5%] bg-gradient-to-t from-gray-50 to-transparent dark:from-gray-800" />
|
||||
</MinimalMessagesWrapper>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-[5%] bg-gradient-to-t from-gray-50 to-transparent dark:from-gray-800" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
206
client/src/routes/__tests__/Search.spec.tsx
Normal file
206
client/src/routes/__tests__/Search.spec.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import React from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { useMessagesInfiniteQuery } from '~/data-provider';
|
||||
import Search from '../Search';
|
||||
|
||||
/* react-virtualized measures nothing in jsdom; render every row flatly so the
|
||||
list contents are exercised. */
|
||||
jest.mock('react-virtualized', () => ({
|
||||
__esModule: true,
|
||||
CellMeasurerCache: class {
|
||||
getHeight() {
|
||||
return 100;
|
||||
}
|
||||
|
||||
clearAll() {}
|
||||
},
|
||||
CellMeasurer: ({
|
||||
children,
|
||||
}: {
|
||||
children: (a: { registerChild: () => void }) => React.ReactNode;
|
||||
}) => children({ registerChild: () => {} }),
|
||||
List: ({
|
||||
rowCount,
|
||||
rowRenderer,
|
||||
onRowsRendered,
|
||||
}: {
|
||||
rowCount: number;
|
||||
rowRenderer: (p: {
|
||||
index: number;
|
||||
key: string;
|
||||
parent: unknown;
|
||||
style: object;
|
||||
}) => React.ReactNode;
|
||||
onRowsRendered?: (p: { startIndex: number; stopIndex: number }) => void;
|
||||
}) => {
|
||||
// expose the near-bottom trigger for the pagination test
|
||||
(globalThis as Record<string, unknown>).__triggerRowsRendered = () =>
|
||||
onRowsRendered?.({ startIndex: 0, stopIndex: rowCount - 1 });
|
||||
return (
|
||||
<div data-testid="virtual-list">
|
||||
{Array.from({ length: rowCount }, (_, i) =>
|
||||
rowRenderer({ index: i, key: String(i), parent: {}, style: {} }),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('recoil', () => ({ useRecoilValue: jest.fn() }));
|
||||
jest.mock('~/data-provider', () => ({ useMessagesInfiniteQuery: jest.fn() }));
|
||||
jest.mock('~/Providers', () => ({ useFileMapContext: () => ({}) }));
|
||||
jest.mock('@librechat/client', () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
useToastContext: () => ({ showToast: jest.fn() }),
|
||||
}));
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useAuthContext: () => ({ isAuthenticated: true }),
|
||||
useElementSize: () => ({ ref: () => {}, width: 800, height: 600 }),
|
||||
}));
|
||||
jest.mock('~/store', () => ({ __esModule: true, default: { search: 'search-atom' } }));
|
||||
jest.mock('~/components/Chat/Messages/SearchMessage', () => ({
|
||||
__esModule: true,
|
||||
default: ({ message }: { message: { text: string } }) => (
|
||||
<div className="search-row">{message.text}</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('~/utils', () => ({ cn: (...a: unknown[]) => a.filter(Boolean).join(' ') }));
|
||||
jest.mock('jotai', () => ({ useAtomValue: () => 'text-base' }));
|
||||
jest.mock('~/store/fontSize', () => ({ fontSizeAtom: {} }));
|
||||
|
||||
const mockUseRecoilValue = useRecoilValue as jest.Mock;
|
||||
const mockUseQuery = useMessagesInfiniteQuery as jest.Mock;
|
||||
|
||||
const searchState = (over: Record<string, unknown> = {}) => ({
|
||||
enabled: true,
|
||||
query: 'zephyrine',
|
||||
debouncedQuery: 'zephyrine',
|
||||
isSearching: false,
|
||||
isTyping: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
const queryResult = (over: Record<string, unknown> = {}) => ({
|
||||
data: { pages: [{ messages: [{ messageId: 'm1', text: 'row one' }], nextCursor: null }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
fetchNextPage: jest.fn(),
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: false,
|
||||
isPreviousData: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('Search route', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('renders result rows when data is present', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(queryResult());
|
||||
render(<Search />);
|
||||
expect(screen.getByText('row one')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps results mounted while typing (does NOT flash the full spinner)', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState({ isTyping: true }));
|
||||
mockUseQuery.mockReturnValue(queryResult());
|
||||
render(<Search />);
|
||||
// The whole list must stay — this is the core regression fix.
|
||||
expect(screen.getByText('row one')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the full spinner only on initial load with no results', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(queryResult({ isLoading: true, data: undefined }));
|
||||
render(<Search />);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('row one')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows nothing-found when the query returned no results', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(
|
||||
queryResult({ data: { pages: [{ messages: [], nextCursor: null }] } }),
|
||||
);
|
||||
render(<Search />);
|
||||
expect(screen.getAllByText('com_ui_nothing_found').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the spinner (not a false nothing-found) when stale empty data is held during a refetch', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(
|
||||
queryResult({
|
||||
data: { pages: [{ messages: [], nextCursor: null }] },
|
||||
isPreviousData: true,
|
||||
}),
|
||||
);
|
||||
render(<Search />);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_nothing_found')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when there is no query and the user is idle', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState({ debouncedQuery: '' }));
|
||||
mockUseQuery.mockReturnValue(queryResult({ data: undefined }));
|
||||
const { container } = render(<Search />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows the spinner during the initial debounce (query typed, not yet debounced)', () => {
|
||||
mockUseRecoilValue.mockReturnValue(
|
||||
searchState({ query: 'zephyrine', debouncedQuery: '', isTyping: true }),
|
||||
);
|
||||
mockUseQuery.mockReturnValue(queryResult({ data: undefined }));
|
||||
render(<Search />);
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('announces empty results through a live region', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(
|
||||
queryResult({ data: { pages: [{ messages: [], nextCursor: null }] } }),
|
||||
);
|
||||
render(<Search />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('com_ui_nothing_found');
|
||||
});
|
||||
|
||||
it('fetches the next page when scrolled near the bottom', () => {
|
||||
const fetchNextPage = jest.fn();
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(queryResult({ hasNextPage: true, fetchNextPage }));
|
||||
render(<Search />);
|
||||
(globalThis as unknown as Record<string, () => void>).__triggerRowsRendered();
|
||||
expect(fetchNextPage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT paginate the outgoing query while the user is still typing', () => {
|
||||
const fetchNextPage = jest.fn();
|
||||
mockUseRecoilValue.mockReturnValue(searchState({ isTyping: true }));
|
||||
mockUseQuery.mockReturnValue(queryResult({ hasNextPage: true, fetchNextPage }));
|
||||
render(<Search />);
|
||||
(globalThis as unknown as Record<string, () => void>).__triggerRowsRendered();
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT paginate while previous-query results are still mounted (refetch in flight)', () => {
|
||||
const fetchNextPage = jest.fn();
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(
|
||||
queryResult({ hasNextPage: true, isPreviousData: true, fetchNextPage }),
|
||||
);
|
||||
render(<Search />);
|
||||
(globalThis as unknown as Record<string, () => void>).__triggerRowsRendered();
|
||||
expect(fetchNextPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a trailing spacer row so the last result clears the bottom overlay', () => {
|
||||
mockUseRecoilValue.mockReturnValue(searchState());
|
||||
mockUseQuery.mockReturnValue(queryResult());
|
||||
render(<Search />);
|
||||
expect(screen.getByText('row one')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('search-footer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue