Skip to content

Commit 9f8f032

Browse files
MelvinBotmkhutornyi
andcommitted
Stop the Search page resending a live first-page request, which flashed the footer skeleton after Select all
Co-authored-by: mkhutornyi <mkhutornyi@users.noreply.github.com>
1 parent c673a18 commit 9f8f032

3 files changed

Lines changed: 64 additions & 3 deletions

File tree

‎src/hooks/useSearchPageSetup.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {useSearchQueryContext, useSearchResultsContext, useSearchSelectionAction
22
import type {SearchQueryJSON} from '@components/Search/types';
33

44
import {saveLastSearchParams} from '@libs/actions/ReportNavigation';
5-
import {clearPageRequestedSearch, markPageRequestedSearch, openSearch, search} from '@libs/actions/Search';
5+
import {clearPageRequestedSearch, isSearchRequestInFlight, markPageRequestedSearch, openSearch, search} from '@libs/actions/Search';
66
import {hasPendingSearchWrite} from '@libs/pendingSearchWrite';
77
import {isSearchDataLoaded, isSearchPending} from '@libs/SearchUIUtils';
88

@@ -64,6 +64,9 @@ function useSearchPageSetup(queryJSON: Readonly<SearchQueryJSON> | undefined) {
6464
// Hashes this page requested, so an error can be traced to the attempt that produced it.
6565
const requestedHashesRef = useRef<Set<number>>(new Set());
6666

67+
// The last query this page sent with shouldSaveRecentSearch.
68+
const flaggedHashRef = useRef<number | undefined>(undefined);
69+
6770
// Clear selected transactions when navigating to a different search query
6871
function clearOnHashChange() {
6972
if (hash === undefined) {
@@ -93,13 +96,17 @@ function useSearchPageSetup(queryJSON: Readonly<SearchQueryJSON> | undefined) {
9396
}
9497

9598
// A pending initial request may be stale after reload and can be restarted through request deduplication.
96-
// Pagination must not restart page one.
97-
if (isSnapshotDataLoaded && !isInitialSearchPending) {
99+
// Pagination must not restart page one. Once this visit has sent the query flagged, a live first-page request
100+
// (select-all totals, a refresh) is programmatic: re-sending it flagged would only queue a duplicate request,
101+
// which flashes the footer's loading skeleton after the totals have already arrived.
102+
const isLiveRequestAlreadyFlagged = flaggedHashRef.current === hash && isSearchRequestInFlight(hash);
103+
if (isSnapshotDataLoaded && (!isInitialSearchPending || isLiveRequestAlreadyFlagged)) {
98104
return;
99105
}
100106

101107
const shouldSkipWaitForWrites = hasPendingSearchWrite();
102108
requestedHashesRef.current.add(hash);
109+
flaggedHashRef.current = hash;
103110
// Claim this query's first page so Search does not request it again when it mounts behind the skeleton.
104111
// With data loaded Search is already mounted, so a token set here would never be read and would skip the next revisit.
105112
if (!isSnapshotDataLoaded) {

‎src/libs/actions/Search.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1192,6 +1192,11 @@ function clearPageRequestedSearch() {
11921192
pageRequestedSearch = undefined;
11931193
}
11941194

1195+
/** Whether a request for this query's page was sent in this session and is still awaiting its response. */
1196+
function isSearchRequestInFlight(hash: number, offset = 0) {
1197+
return inFlightSearchRequests.has(`${hash}_${offset}`);
1198+
}
1199+
11951200
let shouldPreventSearchAPI = false;
11961201
function handlePreventSearchAPI(hash: number | undefined) {
11971202
if (typeof hash === 'undefined') {
@@ -2611,5 +2616,6 @@ export {
26112616
markPageRequestedSearch,
26122617
consumePageRequestedSearch,
26132618
clearPageRequestedSearch,
2619+
isSearchRequestInFlight,
26142620
};
26152621
export type {TransactionPreviewData};

‎tests/unit/hooks/useSearchPageSetup.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {SearchResults} from '@src/types/onyx';
1010
import type * as NativeNavigation from '@react-navigation/native';
1111

1212
const mockSearch = jest.fn<void, unknown[]>();
13+
let mockIsSearchRequestInFlight = false;
1314
let mockCurrentSearchResults: SearchResults | undefined;
1415
let mockShouldUseLiveData = false;
1516

@@ -33,6 +34,7 @@ jest.mock('@libs/actions/Search', () => ({
3334
search: (...args: unknown[]) => mockSearch(...args),
3435
markPageRequestedSearch: jest.fn(),
3536
clearPageRequestedSearch: jest.fn(),
37+
isSearchRequestInFlight: () => mockIsSearchRequestInFlight,
3638
}));
3739

3840
jest.mock('@react-navigation/native', () => ({
@@ -88,6 +90,7 @@ describe('useSearchPageSetup', () => {
8890
beforeEach(() => {
8991
mockSearch.mockClear();
9092
mockShouldUseLiveData = false;
93+
mockIsSearchRequestInFlight = false;
9194
});
9295

9396
it('retries an unresolved search when temporary search prevention clears', async () => {
@@ -118,6 +121,51 @@ describe('useSearchPageSetup', () => {
118121
expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({isLoading: false}));
119122
});
120123

124+
it('does not resend a live first-page request after the page already sent this query', async () => {
125+
// Given the page already sent the first page of this query, and its response has loaded
126+
const queryJSON = getQueryJSON();
127+
const {rerender} = renderHook(
128+
({searchResults}) => {
129+
mockCurrentSearchResults = searchResults;
130+
useSearchPageSetup(queryJSON);
131+
},
132+
{initialProps: {searchResults: makeUnresolvedSearchResults(queryJSON.hash, true)}},
133+
);
134+
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
135+
rerender({searchResults: makeCachedSearchResults(queryJSON.hash, false, CONST.SEARCH.SNAPSHOT_STATE.LOADED)});
136+
137+
// When Search sends its own first-page request (for example the select-all totals request)
138+
mockIsSearchRequestInFlight = true;
139+
rerender({searchResults: makeCachedSearchResults(queryJSON.hash, true, CONST.SEARCH.SNAPSHOT_STATE.LOADING)});
140+
await Promise.resolve();
141+
142+
// Then the page does not send it again, because the resent request would run as a duplicate once the first
143+
// one returns and show the footer's loading skeleton after the totals arrived
144+
expect(mockSearch).toHaveBeenCalledTimes(1);
145+
});
146+
147+
it('sends a cached query once when Search starts its first-page request', async () => {
148+
// Given a cached query that the page did not need to request on this visit
149+
const queryJSON = getQueryJSON();
150+
const {rerender} = renderHook(
151+
({searchResults}) => {
152+
mockCurrentSearchResults = searchResults;
153+
useSearchPageSetup(queryJSON);
154+
},
155+
{initialProps: {searchResults: makeCachedSearchResults(queryJSON.hash, false, CONST.SEARCH.SNAPSHOT_STATE.LOADED)}},
156+
);
157+
await Promise.resolve();
158+
expect(mockSearch).not.toHaveBeenCalled();
159+
160+
// When Search sends its own first-page request for it
161+
mockIsSearchRequestInFlight = true;
162+
rerender({searchResults: makeCachedSearchResults(queryJSON.hash, true, CONST.SEARCH.SNAPSHOT_STATE.LOADING)});
163+
164+
// Then the page still sends the query once, so the visit is saved to recent searches
165+
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
166+
expect(mockSearch).toHaveBeenCalledWith(expect.objectContaining({shouldSaveRecentSearch: true}));
167+
});
168+
121169
it('does not restart the first page while a later page is loading', async () => {
122170
const queryJSON = getQueryJSON();
123171
mockCurrentSearchResults = makeCachedSearchResults(queryJSON.hash, true, CONST.SEARCH.SNAPSHOT_STATE.LOADING, CONST.SEARCH.RESULTS_PAGE_SIZE);

0 commit comments

Comments
 (0)