Skip to content

Commit 1694c09

Browse files
authored
Merge pull request Expensify#83917 from callstack-internal/callstack-internal/szymonzalarski/search/move-skeleton-to-top-level-search-page
Refactor search functionality to improve loading state handling
2 parents 75c56bc + 73a6a1b commit 1694c09

9 files changed

Lines changed: 255 additions & 99 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react';
2+
import type {StyleProp, ViewStyle} from 'react-native';
3+
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated';
4+
import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton';
5+
import useThemeStyles from '@hooks/useThemeStyles';
6+
import {endSpanWithAttributes} from '@libs/telemetry/activeSpans';
7+
import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan';
8+
import CONST from '@src/CONST';
9+
10+
type SearchLoadingSkeletonProps = {
11+
containerStyle?: StyleProp<ViewStyle>;
12+
reasonAttributes?: SkeletonSpanReasonAttributes;
13+
};
14+
15+
function SearchLoadingSkeleton({containerStyle, reasonAttributes}: SearchLoadingSkeletonProps) {
16+
const styles = useThemeStyles();
17+
18+
return (
19+
<Animated.View
20+
entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)}
21+
exiting={FadeOut.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)}
22+
style={[styles.flex1]}
23+
onLayout={() => {
24+
endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false});
25+
}}
26+
>
27+
<SearchRowSkeleton
28+
shouldAnimate
29+
containerStyle={containerStyle}
30+
reasonAttributes={reasonAttributes}
31+
/>
32+
</Animated.View>
33+
);
34+
}
35+
36+
export default SearchLoadingSkeleton;

src/components/Search/index.tsx

Lines changed: 4 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
44
import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native';
55
import {View} from 'react-native';
66
import type {OnyxEntry} from 'react-native-onyx';
7-
import Animated, {FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
7+
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
88
import FullPageErrorView from '@components/BlockingViews/FullPageErrorView';
99
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
1010
import {ModalActions} from '@components/Modal/Global/ModalContext';
@@ -36,7 +36,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
3636
import {openOldDotLink} from '@libs/actions/Link';
3737
import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
3838
import type {TransactionPreviewData} from '@libs/actions/Search';
39-
import {openSearch, setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search';
39+
import {setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search';
4040
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
4141
import Log from '@libs/Log';
4242
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
@@ -51,6 +51,7 @@ import {
5151
getListItem,
5252
getSections,
5353
getSortedSections,
54+
getValidGroupBy,
5455
getWideAmountIndicators,
5556
isGroupedItemArray,
5657
isReportActionListItemType,
@@ -229,7 +230,6 @@ function Search({
229230
const navigation = useNavigation<PlatformStackNavigationProp<SearchFullscreenNavigatorParamList>>();
230231
const isFocused = useIsFocused();
231232
const {markReportIDAsExpense} = useWideRHPActions();
232-
233233
const {
234234
currentSearchHash,
235235
currentSearchKey,
@@ -241,7 +241,6 @@ function Search({
241241
shouldUseLiveData,
242242
suggestedSearches,
243243
} = useSearchStateContext();
244-
245244
const {setSelectedTransactions, clearSelectedTransactions, setShouldShowFiltersBarLoading, setShouldShowSelectAllMatchingItems, selectAllMatchingItems, setShouldResetSearchQuery} =
246245
useSearchActionsContext();
247246
const [offset, setOffset] = useState(0);
@@ -306,7 +305,7 @@ function Search({
306305
}
307306
}, [onDEWModalOpen, showConfirmModal, translate]);
308307

309-
const validGroupBy = groupBy && Object.values(CONST.SEARCH.GROUP_BY).includes(groupBy) ? groupBy : undefined;
308+
const validGroupBy = getValidGroupBy(groupBy);
310309
const prevValidGroupBy = usePrevious(validGroupBy);
311310
const isSearchResultsEmpty = !searchResults?.data || isSearchResultsEmptyUtil(searchResults, validGroupBy);
312311

@@ -354,17 +353,6 @@ function Search({
354353
// eslint-disable-next-line react-hooks/exhaustive-deps
355354
}, [isSmallScreenWidth]);
356355

357-
useEffect(() => {
358-
openSearch({includePartiallySetupBankAccounts: true});
359-
}, []);
360-
361-
useEffect(() => {
362-
if (!prevIsOffline || isOffline) {
363-
return;
364-
}
365-
openSearch({includePartiallySetupBankAccounts: true});
366-
}, [isOffline, prevIsOffline]);
367-
368356
const {newSearchResultKeys, handleSelectionListScroll, newTransactions} = useSearchHighlightAndScroll({
369357
searchResults,
370358
transactions,
@@ -449,21 +437,6 @@ function Search({
449437

450438
const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0;
451439

452-
const loadingSkeletonReasonAttributes = useMemo<SkeletonSpanReasonAttributes>(
453-
() => ({
454-
context: 'Search',
455-
isOffline,
456-
isDataLoaded,
457-
isCardFeedsLoading,
458-
isSearchLoading: !!searchResults?.search?.isLoading,
459-
hasEmptyData: Array.isArray(searchResults?.data) && searchResults?.data.length === 0,
460-
hasErrors,
461-
hasPendingResponse: searchRequestResponseStatusCode === null,
462-
shouldUseLiveData,
463-
}),
464-
[isOffline, isDataLoaded, isCardFeedsLoading, searchResults?.search?.isLoading, searchResults?.data, hasErrors, searchRequestResponseStatusCode, shouldUseLiveData],
465-
);
466-
467440
const loadMoreSkeletonReasonAttributes = useMemo<SkeletonSpanReasonAttributes>(
468441
() => ({
469442
context: 'Search.ListFooter',
@@ -1333,11 +1306,6 @@ function Search({
13331306
spanExistedOnMount.current = false;
13341307
}, []);
13351308

1336-
const onLayoutSkeleton = useCallback(() => {
1337-
hasHadFirstLayout.current = true;
1338-
endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false});
1339-
}, []);
1340-
13411309
const onLayoutChart = useCallback(() => {
13421310
hasHadFirstLayout.current = true;
13431311
endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true});
@@ -1362,23 +1330,6 @@ function Search({
13621330
}, [shouldShowLoadingState]),
13631331
);
13641332

1365-
if (shouldShowLoadingState) {
1366-
return (
1367-
<Animated.View
1368-
entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)}
1369-
exiting={FadeOut.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)}
1370-
style={[styles.flex1]}
1371-
onLayout={onLayoutSkeleton}
1372-
>
1373-
<SearchRowSkeleton
1374-
shouldAnimate
1375-
containerStyle={shouldUseNarrowLayout ? styles.searchListContentContainerStyles : styles.mt3}
1376-
reasonAttributes={loadingSkeletonReasonAttributes}
1377-
/>
1378-
</Animated.View>
1379-
);
1380-
}
1381-
13821333
if (searchResults === undefined) {
13831334
Log.alert('[Search] Undefined search type');
13841335
cancelNavigationSpans();

src/hooks/useSearchLoadingState.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {useSearchStateContext} from '@components/Search/SearchContext';
2+
import type {SearchQueryJSON} from '@components/Search/types';
3+
import {getValidGroupBy} from '@libs/SearchUIUtils';
4+
import CONST from '@src/CONST';
5+
import ONYXKEYS from '@src/ONYXKEYS';
6+
import type {SearchResults} from '@src/types/onyx';
7+
import useNetwork from './useNetwork';
8+
import useOnyx from './useOnyx';
9+
10+
/**
11+
* Computes whether the search page should show a loading skeleton.
12+
* Accepts searchResults from the caller (which may include a sorting fallback)
13+
* rather than reading raw context data, so that sorting doesn't trigger a skeleton flash.
14+
*/
15+
function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined, searchResults: SearchResults | undefined): boolean {
16+
const {isOffline} = useNetwork();
17+
const {shouldUseLiveData} = useSearchStateContext();
18+
const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER);
19+
20+
if (shouldUseLiveData || isOffline || !queryJSON) {
21+
return false;
22+
}
23+
24+
const hasNoData = searchResults?.data === undefined;
25+
const validGroupBy = getValidGroupBy(queryJSON.groupBy);
26+
const isCardFeedsLoading = validGroupBy === CONST.SEARCH.GROUP_BY.CARD && cardFeedsResult?.status === 'loading';
27+
28+
// Show page-level skeleton when no data has ever arrived for this query,
29+
// or when card feeds are still loading for card-grouped searches.
30+
// Once data arrives (even empty []), Search mounts and handles its own
31+
// loading/empty states internally via shouldShowLoadingState.
32+
return hasNoData || isCardFeedsLoading;
33+
}
34+
35+
export default useSearchLoadingState;

src/hooks/useSearchPageSetup.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {useFocusEffect} from '@react-navigation/native';
2+
import {useCallback, useEffect} from 'react';
3+
import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext';
4+
import type {SearchQueryJSON} from '@components/Search/types';
5+
import {openSearch, search} from '@libs/actions/Search';
6+
import {isSearchDataLoaded} from '@libs/SearchUIUtils';
7+
import useNetwork from './useNetwork';
8+
import usePrevious from './usePrevious';
9+
import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals';
10+
11+
/**
12+
* Handles page-level setup for Search that must happen before the Search component mounts:
13+
* - Clears selected transactions when the query changes
14+
* - Fires the search() API call so data starts loading alongside the skeleton
15+
* - Fires openSearch() to load bank account data
16+
* - Re-fires openSearch() when coming back online
17+
*/
18+
function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) {
19+
const {isOffline} = useNetwork();
20+
const prevIsOffline = usePrevious(isOffline);
21+
const {clearSelectedTransactions} = useSearchActionsContext();
22+
const {shouldUseLiveData, currentSearchResults, currentSearchKey} = useSearchStateContext();
23+
24+
const hash = queryJSON?.hash;
25+
const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true);
26+
27+
// Clear selected transactions when navigating to a different search query
28+
const clearOnHashChange = useCallback(() => {
29+
if (hash === undefined) {
30+
return;
31+
}
32+
clearSelectedTransactions(hash);
33+
}, [hash, clearSelectedTransactions]);
34+
35+
useFocusEffect(clearOnHashChange);
36+
37+
// useEffect supplements useFocusEffect: it handles both the initial mount
38+
// and cases where route params change without a navigation event (e.g. sorting).
39+
useEffect(clearOnHashChange, [clearOnHashChange]);
40+
41+
// Fire search() when the query changes (hash). This runs at the page level so the
42+
// API request starts in parallel with the skeleton, before Search mounts its 14+ useOnyx hooks.
43+
// currentSearchResults is intentionally read but not in deps — search should fire once per
44+
// query change, not re-trigger on every data update from Onyx.
45+
useEffect(() => {
46+
if (!queryJSON || hash === undefined || shouldUseLiveData || isOffline) {
47+
return;
48+
}
49+
if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) {
50+
return;
51+
}
52+
search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false});
53+
}, [hash, isOffline, shouldUseLiveData, queryJSON]);
54+
55+
useEffect(() => {
56+
openSearch({includePartiallySetupBankAccounts: true});
57+
}, []);
58+
59+
useEffect(() => {
60+
if (!prevIsOffline || isOffline) {
61+
return;
62+
}
63+
openSearch({includePartiallySetupBankAccounts: true});
64+
}, [isOffline, prevIsOffline]);
65+
}
66+
67+
export default useSearchPageSetup;

src/libs/SearchUIUtils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3946,6 +3946,10 @@ function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON:
39463946
return isDataLoaded;
39473947
}
39483948

3949+
function getValidGroupBy(groupBy: string | undefined): ValueOf<typeof CONST.SEARCH.GROUP_BY> | undefined {
3950+
return groupBy && Object.values(CONST.SEARCH.GROUP_BY).includes(groupBy as ValueOf<typeof CONST.SEARCH.GROUP_BY>) ? (groupBy as ValueOf<typeof CONST.SEARCH.GROUP_BY>) : undefined;
3951+
}
3952+
39493953
function getStatusOptions(translate: LocalizedTranslate, type: SearchDataTypes) {
39503954
switch (type) {
39513955
case CONST.SEARCH.DATA_TYPES.INVOICE:
@@ -4787,6 +4791,7 @@ export {
47874791
shouldShowEmptyState,
47884792
compareValues,
47894793
isSearchDataLoaded,
4794+
getValidGroupBy,
47904795
getStatusOptions,
47914796
getTypeOptions,
47924797
getGroupByOptions,

src/libs/actions/Search.ts

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,11 @@ function openSearchPage({includePartiallySetupBankAccounts}: OpenSearchPageParam
444444
API.read(READ_COMMANDS.OPEN_SEARCH_PAGE, {includePartiallySetupBankAccounts});
445445
}
446446

447+
// Tracks in-flight search requests by hash+offset to prevent duplicate API calls
448+
// when both page-level (useSearchPageSetup) and Search-internal (handleSearch) effects
449+
// fire for the same query. Cleared when the request completes.
450+
const inFlightSearchRequests = new Set<string>();
451+
447452
let shouldPreventSearchAPI = false;
448453
function handlePreventSearchAPI(hash: number | undefined) {
449454
if (typeof hash === 'undefined') {
@@ -487,6 +492,12 @@ function search({
487492
return;
488493
}
489494

495+
const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`;
496+
if (inFlightSearchRequests.has(dedupeKey)) {
497+
return;
498+
}
499+
inFlightSearchRequests.add(dedupeKey);
500+
490501
const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals);
491502
const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON;
492503
const query = {
@@ -508,36 +519,40 @@ function search({
508519

509520
return waitForWrites(READ_COMMANDS.SEARCH).then(() => {
510521
// eslint-disable-next-line rulesdir/no-api-side-effects-method
511-
return API.makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}).then((result) => {
512-
const response = result?.onyxData?.[0]?.value as OnyxSearchResponse;
513-
const reports = Object.keys(response?.data ?? {})
514-
.filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT))
515-
.map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, ''));
516-
if (response?.search?.offset) {
517-
// Indicates that search results are extended from the Report view (with navigation between reports),
518-
// using previous results to enable correct counter behavior.
519-
if (prevReportsLength) {
522+
return API.makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData})
523+
.then((result) => {
524+
const response = result?.onyxData?.[0]?.value as OnyxSearchResponse;
525+
const reports = Object.keys(response?.data ?? {})
526+
.filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT))
527+
.map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, ''));
528+
if (response?.search?.offset) {
529+
// Indicates that search results are extended from the Report view (with navigation between reports),
530+
// using previous results to enable correct counter behavior.
531+
if (prevReportsLength) {
532+
saveLastSearchParams({
533+
queryJSON,
534+
offset,
535+
hasMoreResults: !!response?.search?.hasMoreResults,
536+
previousLengthOfResults: prevReportsLength,
537+
allowPostSearchRecount: false,
538+
});
539+
}
540+
} else {
541+
// Applies to all searches from the Search View
520542
saveLastSearchParams({
521543
queryJSON,
522544
offset,
523545
hasMoreResults: !!response?.search?.hasMoreResults,
524-
previousLengthOfResults: prevReportsLength,
525-
allowPostSearchRecount: false,
546+
previousLengthOfResults: reports.length,
547+
allowPostSearchRecount: true,
526548
});
527549
}
528-
} else {
529-
// Applies to all searches from the Search View
530-
saveLastSearchParams({
531-
queryJSON,
532-
offset,
533-
hasMoreResults: !!response?.search?.hasMoreResults,
534-
previousLengthOfResults: reports.length,
535-
allowPostSearchRecount: true,
536-
});
537-
}
538550

539-
return result?.jsonCode;
540-
});
551+
return result?.jsonCode;
552+
})
553+
.finally(() => {
554+
inFlightSearchRequests.delete(dedupeKey);
555+
});
541556
});
542557
}
543558

0 commit comments

Comments
 (0)