🪟 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

* 🪟 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:
Danny Avila 2026-07-22 04:26:47 -04:00 committed by GitHub
parent 8751cc1c5c
commit af7b2761eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 623 additions and 55 deletions

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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>
);
}

View 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();
});
});