From 7ad4cba8ee42cfca23cf65bd9c1033ab139ba978 Mon Sep 17 00:00:00 2001 From: Tomasz Misiukiewicz Date: Fri, 11 Sep 2026 11:51:01 +0200 Subject: [PATCH] Paginate live to-do searches --- .../Search/SearchResultsProvider.tsx | 3 +- .../Search/hooks/useSearchSnapshot.ts | 9 ++++-- src/components/Search/index.tsx | 8 +++++ tests/unit/Search/useSearchSnapshotTest.ts | 32 +++++++++++++++++++ 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/components/Search/SearchResultsProvider.tsx b/src/components/Search/SearchResultsProvider.tsx index 4f83c08a85ab..b50051284e41 100644 --- a/src/components/Search/SearchResultsProvider.tsx +++ b/src/components/Search/SearchResultsProvider.tsx @@ -62,8 +62,9 @@ function SearchResultsProvider({children}: SearchResultsProviderProps) { const hasResults = Object.keys(liveData.data).length > 0; // For to-do searches, always return a valid SearchResults object (even with empty data) // This ensures we show the empty state instead of loading/blocking views + // don't force isLoading off, the load-more skeleton reads it to tell a page is in flight currentSearchResults = { - search: {...searchInfo, isLoading: false, hasResults}, + search: {...searchInfo, hasResults}, data: liveData.data, }; } else { diff --git a/src/components/Search/hooks/useSearchSnapshot.ts b/src/components/Search/hooks/useSearchSnapshot.ts index 3021eff2948d..9408fb870bee 100644 --- a/src/components/Search/hooks/useSearchSnapshot.ts +++ b/src/components/Search/hooks/useSearchSnapshot.ts @@ -76,6 +76,8 @@ type UseSearchSnapshotParams = { * full-collection reads. */ transactions: OptimisticTrackingParams['transactions']; reportActions: OptimisticTrackingParams['reportActions']; + /** Row cap for `data`. Live searches page on this instead of a server cursor. `undefined` = no cap. */ + visibleRowLimit?: number; }; const EMPTY_DATA: SearchListItem[] = []; @@ -93,7 +95,7 @@ const hashToString = (queryHash?: number) => (queryHash || queryHash === 0 ? Str * per-group sub-snapshots, and absorbs the optimistic-row resilience. Returns the sorted rows plus the * list-level meta and the optimistic-tracking carriers that `` consumes. */ -function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions}: UseSearchSnapshotParams): SearchSnapshotResult { +function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, transactions, reportActions, visibleRowLimit}: UseSearchSnapshotParams): SearchSnapshotResult { const {type, sortBy, sortOrder, hash, groupBy} = queryJSON; const {isOffline} = useNetwork(); @@ -401,8 +403,11 @@ function useSearchSnapshot({queryJSON, searchResults, newSearchResultKeys, trans return item.transactions.length === 0 || !subSnapshot || !subSnapshot?.search?.hasMoreResults; }); + // slice after the sort so page 2 continues the order on screen + const visibleData = visibleRowLimit !== undefined && stableSortedData.length > visibleRowLimit ? stableSortedData.slice(0, visibleRowLimit) : stableSortedData; + return { - data: stableSortedData, + data: visibleData, chartData, filteredData, filteredDataLength, diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 350162b54d9a..4d059a3983ac 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -187,6 +187,13 @@ function Search({ const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); + // one page of local rows per resolved snapshot offset. offset is written optimistically at request time, + // so hold the limit there until isLoading clears, otherwise the next rows show before the response lands + const liveRowLimit = Math.max( + CONST.SEARCH.RESULTS_PAGE_SIZE, + searchResults?.search?.isLoading ? (searchResults?.search?.offset ?? 0) : (searchResults?.search?.offset ?? 0) + CONST.SEARCH.RESULTS_PAGE_SIZE, + ); + const searchDataType = useMemo(() => (shouldUseLiveData ? CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT : searchResults?.search?.type), [shouldUseLiveData, searchResults?.search?.type]); const isExpenseAllMatchingSelection = type === CONST.SEARCH.DATA_TYPES.EXPENSE && areAllMatchingItemsSelected; const isAllMatchingItemsCountMissing = isExpenseAllMatchingSelection && typeof searchResults?.search?.count !== 'number'; @@ -282,6 +289,7 @@ function Search({ newSearchResultKeys, transactions, reportActions, + visibleRowLimit: shouldUseLiveData ? liveRowLimit : undefined, }); // Mirror `hasQueuedHighlights` into a ref so the post-create-flow `useFocusEffect` diff --git a/tests/unit/Search/useSearchSnapshotTest.ts b/tests/unit/Search/useSearchSnapshotTest.ts index a2c0b2531f1f..bf01539f93c3 100644 --- a/tests/unit/Search/useSearchSnapshotTest.ts +++ b/tests/unit/Search/useSearchSnapshotTest.ts @@ -429,4 +429,36 @@ describe('useSearchSnapshot', () => { expect(result.current.chartData).toBe(firstChartData); expect(result.current.data).toBe(firstData); }); + + it('caps data at visibleRowLimit while leaving chartData and the row count untouched', () => { + const searchResults = makeSearchResults(); + mockUseOptimisticSearchTracking.mockReturnValue(trackingReturn(searchResults.data)); + const rows = Array.from({length: 5}, (_value, index) => ({transactionID: `${index}`, keyForList: `${index}`})); + mockGetSections.mockReturnValue([rows, rows.length, false]); + mockGetSortedSections.mockReturnValue(rows); + + const {result, rerender} = renderHook((visibleRowLimit?: number) => + useSearchSnapshot({ + queryJSON: makeQueryJSON(), + searchResults, + newSearchResultKeys: undefined, + transactions: undefined, + reportActions: undefined, + visibleRowLimit, + }), + ); + + expect(result.current.data).toHaveLength(5); + + rerender(2); + + expect(result.current.data.map((item) => item.keyForList)).toEqual(['0', '1']); + expect(result.current.chartData).toHaveLength(5); + expect(result.current.filteredDataLength).toBe(5); + + rerender(10); + + // a fresh array here re-renders the list on every pass + expect(result.current.data).toBe(result.current.chartData); + }); });