From 5af078201535959033c1a3527bfa97ac81834824 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Thu, 5 Mar 2026 16:25:51 +0100 Subject: [PATCH 01/14] Refactor Search component and add loading skeletons --- .../Search/SearchLoadingSkeleton.tsx | 33 +++++++++ src/components/Search/index.tsx | 66 ++--------------- src/hooks/useSearchLoadingState.ts | 30 ++++++++ src/hooks/useSearchPageSetup.ts | 73 +++++++++++++++++++ src/libs/SearchUIUtils.ts | 5 ++ src/pages/Search/SearchPage.tsx | 2 + src/pages/Search/SearchPageNarrow.tsx | 27 ++++--- src/pages/Search/SearchPageWide.tsx | 27 ++++--- 8 files changed, 183 insertions(+), 80 deletions(-) create mode 100644 src/components/Search/SearchLoadingSkeleton.tsx create mode 100644 src/hooks/useSearchLoadingState.ts create mode 100644 src/hooks/useSearchPageSetup.ts diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx new file mode 100644 index 0000000000000..e54c8994fbf7b --- /dev/null +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {endSpanWithAttributes} from '@libs/telemetry/activeSpans'; +import CONST from '@src/CONST'; + +type SearchLoadingSkeletonProps = { + containerStyle?: StyleProp; +}; + +function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { + const styles = useThemeStyles(); + + return ( + { + endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); + }} + > + + + ); +} + +export default SearchLoadingSkeleton; diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index ccbed243a3ba3..d020f8143e559 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -4,7 +4,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import Animated, {FadeIn, FadeOut, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; +import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import FullPageErrorView from '@components/BlockingViews/FullPageErrorView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -37,7 +37,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {openOldDotLink} from '@libs/actions/Link'; import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import type {TransactionPreviewData} from '@libs/actions/Search'; -import {openSearch, setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; +import {setOptimisticDataForTransactionThreadPreview} from '@libs/actions/Search'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; @@ -52,6 +52,7 @@ import { getSections, getSortedSections, getSuggestedSearches, + getValidGroupBy, getWideAmountIndicators, isGroupedItemArray, isReportActionListItemType, @@ -229,16 +230,8 @@ function Search({ const {markReportIDAsExpense} = useWideRHPActions(); const {currentSearchHash, selectedTransactions, shouldTurnOffSelectionMode, lastSearchType, areAllMatchingItemsSelected, shouldResetSearchQuery, shouldUseLiveData} = useSearchStateContext(); - const { - setCurrentSearchHashAndKey, - setCurrentSearchQueryJSON, - setSelectedTransactions, - clearSelectedTransactions, - setShouldShowFiltersBarLoading, - setShouldShowSelectAllMatchingItems, - selectAllMatchingItems, - setShouldResetSearchQuery, - } = useSearchActionsContext(); + const {setSelectedTransactions, clearSelectedTransactions, setShouldShowFiltersBarLoading, setShouldShowSelectAllMatchingItems, selectAllMatchingItems, setShouldResetSearchQuery} = + useSearchActionsContext(); const [offset, setOffset] = useState(0); const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); @@ -302,22 +295,7 @@ function Search({ } }, [onDEWModalOpen, showConfirmModal, translate]); - const clearTransactionsAndSetHashAndKey = useCallback(() => { - clearSelectedTransactions(hash); - setCurrentSearchHashAndKey(hash, recentSearchHash, searchKey); - setCurrentSearchQueryJSON(queryJSON); - }, [hash, recentSearchHash, searchKey, clearSelectedTransactions, setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, queryJSON]); - - useFocusEffect(clearTransactionsAndSetHashAndKey); - - useEffect(() => { - clearTransactionsAndSetHashAndKey(); - - // Trigger once on mount (e.g., on page reload), when RHP is open and screen is not focused - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const validGroupBy = groupBy && Object.values(CONST.SEARCH.GROUP_BY).includes(groupBy) ? groupBy : undefined; + const validGroupBy = getValidGroupBy(groupBy); const prevValidGroupBy = usePrevious(validGroupBy); const isSearchResultsEmpty = !searchResults?.data || isSearchResultsEmptyUtil(searchResults, validGroupBy); @@ -365,17 +343,6 @@ function Search({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isSmallScreenWidth]); - useEffect(() => { - openSearch({includePartiallySetupBankAccounts: true}); - }, []); - - useEffect(() => { - if (!prevIsOffline || isOffline) { - return; - } - openSearch({includePartiallySetupBankAccounts: true}); - }, [isOffline, prevIsOffline]); - const {newSearchResultKeys, handleSelectionListScroll, newTransactions} = useSearchHighlightAndScroll({ searchResults, transactions, @@ -1184,11 +1151,6 @@ function Search({ spanExistedOnMount.current = false; }, []); - const onLayoutSkeleton = useCallback(() => { - hasHadFirstLayout.current = true; - endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); - }, []); - const onLayoutChart = useCallback(() => { hasHadFirstLayout.current = true; endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: true}); @@ -1207,22 +1169,6 @@ function Search({ }, [shouldShowLoadingState]), ); - if (shouldShowLoadingState) { - return ( - - - - ); - } - if (searchResults === undefined) { Log.alert('[Search] Undefined search type'); cancelNavigationSpans(); diff --git a/src/hooks/useSearchLoadingState.ts b/src/hooks/useSearchLoadingState.ts new file mode 100644 index 0000000000000..05fff8c95b557 --- /dev/null +++ b/src/hooks/useSearchLoadingState.ts @@ -0,0 +1,30 @@ +import {useSearchStateContext} from '@components/Search/SearchContext'; +import type {SearchQueryJSON} from '@components/Search/types'; +import {getValidGroupBy, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; + +/** + * Computes whether the search page should show a loading skeleton + */ +function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined): boolean { + const {isOffline} = useNetwork(); + const {shouldUseLiveData, currentSearchResults} = useSearchStateContext(); + const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); + + if (shouldUseLiveData || isOffline || !queryJSON) { + return false; + } + + const isDataLoaded = isSearchDataLoaded(currentSearchResults, queryJSON); + const isLoadingWithNoData = !!currentSearchResults?.search?.isLoading && Array.isArray(currentSearchResults?.data) && currentSearchResults.data.length === 0; + + const validGroupBy = getValidGroupBy(queryJSON.groupBy); + const isCardFeedsLoading = validGroupBy === CONST.SEARCH.GROUP_BY.CARD && cardFeedsResult?.status === 'loading'; + + return !isDataLoaded || isLoadingWithNoData || isCardFeedsLoading; +} + +export default useSearchLoadingState; diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts new file mode 100644 index 0000000000000..5846a23b0f504 --- /dev/null +++ b/src/hooks/useSearchPageSetup.ts @@ -0,0 +1,73 @@ +import {useEffect} from 'react'; +import {useFocusEffect} from '@react-navigation/native'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import type {SearchQueryJSON} from '@components/Search/types'; +import {openSearch, search} from '@libs/actions/Search'; +import {getSuggestedSearches, isSearchDataLoaded} from '@libs/SearchUIUtils'; +import useCardFeedsForDisplay from './useCardFeedsForDisplay'; +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useNetwork from './useNetwork'; +import usePrevious from './usePrevious'; + +let didOpenSearch = false; + +/** + * Handles page-level setup for Search that must happen before the Search component mounts: + * - Sets the context hash/key so the Onyx subscription points to the correct snapshot + * - Fires the search() API call so data starts loading alongside the skeleton + * - Fires openSearch() to load bank account data + * - Re-fires openSearch() when coming back online + */ +function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { + const {isOffline} = useNetwork(); + const prevIsOffline = usePrevious(isOffline); + const {setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, clearSelectedTransactions} = useSearchActionsContext(); + const {shouldUseLiveData, currentSearchResults} = useSearchStateContext(); + const {accountID} = useCurrentUserPersonalDetails(); + const {defaultCardFeed} = useCardFeedsForDisplay(); + + const suggestedSearches = getSuggestedSearches(accountID, defaultCardFeed?.id); + const hash = queryJSON?.hash; + const recentSearchHash = queryJSON?.recentSearchHash; + const searchKey = recentSearchHash !== undefined ? Object.values(suggestedSearches).find((s) => s.recentSearchHash === recentSearchHash)?.key : undefined; + + const syncContextWithRoute = () => { + if (hash === undefined || recentSearchHash === undefined || !queryJSON) { + return; + } + clearSelectedTransactions(hash); + setCurrentSearchHashAndKey(hash, recentSearchHash, searchKey); + setCurrentSearchQueryJSON(queryJSON); + }; + + useFocusEffect(syncContextWithRoute); + + useEffect(syncContextWithRoute, [syncContextWithRoute]); + + useEffect(() => { + if (!queryJSON || hash === undefined || shouldUseLiveData || isOffline) { + return; + } + if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) { + return; + } + search({queryJSON, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); + }, [hash, searchKey, isOffline, shouldUseLiveData, currentSearchResults, queryJSON]); + + useEffect(() => { + if (didOpenSearch) { + return; + } + didOpenSearch = true; + openSearch({includePartiallySetupBankAccounts: true}); + }, []); + + useEffect(() => { + if (!prevIsOffline || isOffline) { + return; + } + openSearch({includePartiallySetupBankAccounts: true}); + }, [isOffline, prevIsOffline]); +} + +export default useSearchPageSetup; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d29144a21ba02..b896f9465ca7e 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3818,6 +3818,10 @@ function isSearchDataLoaded(searchResults: SearchResults | undefined, queryJSON: return isDataLoaded; } +function getValidGroupBy(groupBy: string | undefined): ValueOf | undefined { + return groupBy && Object.values(CONST.SEARCH.GROUP_BY).includes(groupBy as ValueOf) ? (groupBy as ValueOf) : undefined; +} + function getStatusOptions(translate: LocalizedTranslate, type: SearchDataTypes) { switch (type) { case CONST.SEARCH.DATA_TYPES.INVOICE: @@ -4632,6 +4636,7 @@ export { shouldShowEmptyState, compareValues, isSearchDataLoaded, + getValidGroupBy, getStatusOptions, getTypeOptions, getGroupByOptions, diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 7f2d165f80b01..0240dfa56b08a 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -13,6 +13,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import usePrevious from '@hooks/usePrevious'; import useReceiptScanDrop from '@hooks/useReceiptScanDrop'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchPageSetup from '@hooks/useSearchPageSetup'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -45,6 +46,7 @@ function SearchPage({route}: SearchPageProps) { const lastNonEmptySearchResults = useRef(undefined); useConfirmReadyToOpenApp(); + useSearchPageSetup(queryJSON); useEffect(() => { if (!currentSearchResults?.search?.type) { diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index b02326d0a9c5c..7155e712dbaef 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -13,6 +13,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import Search from '@components/Search'; import {useSearchActionsContext} from '@components/Search/SearchContext'; +import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchFiltersBar from '@components/Search/SearchPageHeader/SearchFiltersBar'; import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; @@ -21,6 +22,7 @@ import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useSearchLoadingState from '@hooks/useSearchLoadingState'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -55,6 +57,7 @@ type SearchPageNarrowProps = { }; function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnabled, metadata, footerData, shouldShowFooter}: SearchPageNarrowProps) { + const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); @@ -238,16 +241,20 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable )} {!searchRouterListVisible && ( - + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} )} {shouldShowFooter && !searchRouterListVisible && ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index bb5c388728ea1..085ae83a21f1a 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -9,12 +9,14 @@ import DropZoneUI from '@components/DropZone/DropZoneUI'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import Search from '@components/Search'; +import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchFiltersBar from '@components/Search/SearchPageHeader/SearchFiltersBar'; import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useSearchLoadingState from '@hooks/useSearchLoadingState'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -60,6 +62,7 @@ function SearchPageWide({ ErrorModal, shouldShowFooter, }: SearchPageWideProps) { + const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON); const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); @@ -113,16 +116,20 @@ function SearchPageWide({ queryJSON={queryJSON} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} /> - + {shouldShowLoadingSkeleton ? ( + + ) : ( + + )} {shouldShowFooter && ( Date: Thu, 5 Mar 2026 17:53:51 +0100 Subject: [PATCH 02/14] Fix mobile-expensify --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 76c27ddb87e83..18d228714828c 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 76c27ddb87e831decfeac7df88638249a31f598c +Subproject commit 18d228714828c735f7b107922f07d01429234df8 From 33f98409a46e92f4bfa89498c3113fdf4dc6b293 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 6 Mar 2026 09:08:35 +0100 Subject: [PATCH 03/14] Refactor search loading state logic and optimize context synchronization in search page setup --- src/hooks/useSearchLoadingState.ts | 4 +++- src/hooks/useSearchPageSetup.ts | 14 +++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/hooks/useSearchLoadingState.ts b/src/hooks/useSearchLoadingState.ts index 05fff8c95b557..2f9e41e5928c8 100644 --- a/src/hooks/useSearchLoadingState.ts +++ b/src/hooks/useSearchLoadingState.ts @@ -24,7 +24,9 @@ function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined): boolean const validGroupBy = getValidGroupBy(queryJSON.groupBy); const isCardFeedsLoading = validGroupBy === CONST.SEARCH.GROUP_BY.CARD && cardFeedsResult?.status === 'loading'; - return !isDataLoaded || isLoadingWithNoData || isCardFeedsLoading; + const hasNoData = currentSearchResults?.data === undefined; + + return (!isDataLoaded && hasNoData) || isLoadingWithNoData || isCardFeedsLoading; } export default useSearchLoadingState; diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 5846a23b0f504..e99ac06f9b1cb 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -1,4 +1,4 @@ -import {useEffect} from 'react'; +import {useCallback, useEffect} from 'react'; import {useFocusEffect} from '@react-navigation/native'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; @@ -9,8 +9,6 @@ import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useNetwork from './useNetwork'; import usePrevious from './usePrevious'; -let didOpenSearch = false; - /** * Handles page-level setup for Search that must happen before the Search component mounts: * - Sets the context hash/key so the Onyx subscription points to the correct snapshot @@ -31,14 +29,16 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { const recentSearchHash = queryJSON?.recentSearchHash; const searchKey = recentSearchHash !== undefined ? Object.values(suggestedSearches).find((s) => s.recentSearchHash === recentSearchHash)?.key : undefined; - const syncContextWithRoute = () => { + // useCallback is required here because useFocusEffect (React Navigation external API) compares callback references. + // React Compiler cannot optimize this — it doesn't know useFocusEffect's internal semantics. + const syncContextWithRoute = useCallback(() => { if (hash === undefined || recentSearchHash === undefined || !queryJSON) { return; } clearSelectedTransactions(hash); setCurrentSearchHashAndKey(hash, recentSearchHash, searchKey); setCurrentSearchQueryJSON(queryJSON); - }; + }, [hash, recentSearchHash, searchKey, queryJSON, clearSelectedTransactions, setCurrentSearchHashAndKey, setCurrentSearchQueryJSON]); useFocusEffect(syncContextWithRoute); @@ -55,10 +55,6 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { }, [hash, searchKey, isOffline, shouldUseLiveData, currentSearchResults, queryJSON]); useEffect(() => { - if (didOpenSearch) { - return; - } - didOpenSearch = true; openSearch({includePartiallySetupBankAccounts: true}); }, []); From 87d6f708ce848a6ec9ad4c9db90ede0c8044c54d Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 6 Mar 2026 16:00:32 +0100 Subject: [PATCH 04/14] Retrigger prettier From 70757300bfb697c9f3b526205cd41280de0e9fd9 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 6 Mar 2026 16:26:41 +0100 Subject: [PATCH 05/14] Retrigger prettier From c389d604ee1cf7b8a36cf946646bb35a1b7ee088 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Mon, 9 Mar 2026 07:55:40 +0100 Subject: [PATCH 06/14] Fix failing prettier --- src/hooks/useSearchPageSetup.ts | 2 +- src/pages/Search/SearchPageNarrow.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index e99ac06f9b1cb..37fdf7361aee7 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -1,5 +1,5 @@ -import {useCallback, useEffect} from 'react'; import {useFocusEffect} from '@react-navigation/native'; +import {useCallback, useEffect} from 'react'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import {openSearch, search} from '@libs/actions/Search'; diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index 7155e712dbaef..5eb341310e9e2 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -22,9 +22,9 @@ import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; import useLoadingBarVisibility from '@hooks/useLoadingBarVisibility'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useSearchLoadingState from '@hooks/useSearchLoadingState'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; +import useSearchLoadingState from '@hooks/useSearchLoadingState'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; From e0c5f7df48e28a571c4feedd47dab1347c2c2372 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Tue, 10 Mar 2026 09:20:13 +0100 Subject: [PATCH 07/14] PR fixes --- .../Search/SearchLoadingSkeleton.tsx | 5 ++- src/hooks/useSearchLoadingState.ts | 33 ++++++++++--------- src/hooks/useSearchPageSetup.ts | 21 ++++++++++-- src/pages/Search/SearchPage.tsx | 2 +- src/pages/Search/SearchPageNarrow.tsx | 13 ++++++-- src/pages/Search/SearchPageWide.tsx | 16 +++++++-- 6 files changed, 65 insertions(+), 25 deletions(-) diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx index e54c8994fbf7b..4dc4bbf9f2c32 100644 --- a/src/components/Search/SearchLoadingSkeleton.tsx +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -4,13 +4,15 @@ import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'; import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useThemeStyles from '@hooks/useThemeStyles'; import {endSpanWithAttributes} from '@libs/telemetry/activeSpans'; +import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import CONST from '@src/CONST'; type SearchLoadingSkeletonProps = { containerStyle?: StyleProp; + reasonAttributes?: SkeletonSpanReasonAttributes; }; -function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { +function SearchLoadingSkeleton({containerStyle, reasonAttributes}: SearchLoadingSkeletonProps) { const styles = useThemeStyles(); return ( @@ -25,6 +27,7 @@ function SearchLoadingSkeleton({containerStyle}: SearchLoadingSkeletonProps) { ); diff --git a/src/hooks/useSearchLoadingState.ts b/src/hooks/useSearchLoadingState.ts index 2f9e41e5928c8..ea6ba5f60485a 100644 --- a/src/hooks/useSearchLoadingState.ts +++ b/src/hooks/useSearchLoadingState.ts @@ -1,32 +1,33 @@ import {useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; -import {getValidGroupBy, isSearchDataLoaded} from '@libs/SearchUIUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; +import {isSearchDataLoaded} from '@libs/SearchUIUtils'; +import type {SearchResults} from '@src/types/onyx'; import useNetwork from './useNetwork'; -import useOnyx from './useOnyx'; /** - * Computes whether the search page should show a loading skeleton + * Computes whether the search page should show a loading skeleton. + * Accepts searchResults from the caller (which may include a sorting fallback) + * rather than reading raw context data, so that sorting doesn't trigger a skeleton flash. + * + * Note: This hook intentionally does NOT check isCardFeedsLoading. Card feed loading is handled + * internally by the Search component's shouldShowLoadingState — blocking Search from mounting + * would prevent the API call from firing and create a deadlock. */ -function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined): boolean { +function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined, searchResults: SearchResults | undefined): boolean { const {isOffline} = useNetwork(); - const {shouldUseLiveData, currentSearchResults} = useSearchStateContext(); - const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); + const {shouldUseLiveData} = useSearchStateContext(); if (shouldUseLiveData || isOffline || !queryJSON) { return false; } - const isDataLoaded = isSearchDataLoaded(currentSearchResults, queryJSON); - const isLoadingWithNoData = !!currentSearchResults?.search?.isLoading && Array.isArray(currentSearchResults?.data) && currentSearchResults.data.length === 0; + const isDataLoaded = isSearchDataLoaded(searchResults, queryJSON); + const hasNoData = searchResults?.data === undefined; - const validGroupBy = getValidGroupBy(queryJSON.groupBy); - const isCardFeedsLoading = validGroupBy === CONST.SEARCH.GROUP_BY.CARD && cardFeedsResult?.status === 'loading'; - - const hasNoData = currentSearchResults?.data === undefined; - - return (!isDataLoaded && hasNoData) || isLoadingWithNoData || isCardFeedsLoading; + // Show page-level skeleton ONLY when no data has ever arrived for this query. + // Once data arrives (even empty []), Search mounts and handles its own + // loading/empty states internally via shouldShowLoadingState. + return !isDataLoaded && hasNoData; } export default useSearchLoadingState; diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 37fdf7361aee7..78492613a0238 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import {useCallback, useEffect} from 'react'; +import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import {openSearch, search} from '@libs/actions/Search'; @@ -29,6 +29,13 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { const recentSearchHash = queryJSON?.recentSearchHash; const searchKey = recentSearchHash !== undefined ? Object.values(suggestedSearches).find((s) => s.recentSearchHash === recentSearchHash)?.key : undefined; + // Ref to read currentSearchResults in the search effect without adding it to deps. + // This prevents re-triggering when data arrives — search should fire once per query change. + const currentSearchResultsRef = useRef(currentSearchResults); + useLayoutEffect(() => { + currentSearchResultsRef.current = currentSearchResults; + }, [currentSearchResults]); + // useCallback is required here because useFocusEffect (React Navigation external API) compares callback references. // React Compiler cannot optimize this — it doesn't know useFocusEffect's internal semantics. const syncContextWithRoute = useCallback(() => { @@ -42,17 +49,25 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { useFocusEffect(syncContextWithRoute); + // useEffect supplements useFocusEffect: it handles both the initial mount + // and cases where route params change without a navigation event (e.g. sorting). useEffect(syncContextWithRoute, [syncContextWithRoute]); + // Fire search() when the query changes (hash/searchKey). This runs at the page level so the + // API request starts in parallel with the skeleton, before Search mounts its 14+ useOnyx hooks. + // Uses a ref for currentSearchResults to avoid re-triggering on every data update. + // The search() action has an internal guard (isLoading check) that prevents duplicate calls, + // so if Search's internal handleSearch effect also fires, only one API request will proceed. useEffect(() => { if (!queryJSON || hash === undefined || shouldUseLiveData || isOffline) { return; } - if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) { + const results = currentSearchResultsRef.current; + if (isSearchDataLoaded(results, queryJSON) || results?.search?.isLoading) { return; } search({queryJSON, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); - }, [hash, searchKey, isOffline, shouldUseLiveData, currentSearchResults, queryJSON]); + }, [hash, searchKey, isOffline, shouldUseLiveData, queryJSON]); useEffect(() => { openSearch({includePartiallySetupBankAccounts: true}); diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index e4521ee905042..ced15ed631eee 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -70,7 +70,7 @@ function SearchPage({route}: SearchPageProps) { const [isSorting, setIsSorting] = useState(false); let searchResults: SearchResults | undefined; - if (currentSearchResults?.data) { + if (currentSearchResults?.data !== undefined) { searchResults = currentSearchResults; } else if (isSorting) { searchResults = lastNonEmptySearchResults.current; diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index 5eb341310e9e2..a94d3d19275f3 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -57,7 +57,7 @@ type SearchPageNarrowProps = { }; function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnabled, metadata, footerData, shouldShowFooter}: SearchPageNarrowProps) { - const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON); + const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); @@ -242,7 +242,16 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable {!searchRouterListVisible && ( {shouldShowLoadingSkeleton ? ( - + ) : ( {shouldShowLoadingSkeleton ? ( - + ) : ( Date: Tue, 10 Mar 2026 15:57:58 +0100 Subject: [PATCH 08/14] Retrigger CI/CD checks From da450a5079026eb0b9631f14746a7d0f263d6352 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Tue, 10 Mar 2026 19:45:39 +0100 Subject: [PATCH 09/14] Refactor useSearchPageSetup hook to remove unnecessary useRef and useLayoutEffect --- src/hooks/useSearchPageSetup.ts | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index 78492613a0238..29948fa671938 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -1,5 +1,5 @@ import {useFocusEffect} from '@react-navigation/native'; -import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'; +import {useCallback, useEffect} from 'react'; import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; import {openSearch, search} from '@libs/actions/Search'; @@ -29,13 +29,6 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { const recentSearchHash = queryJSON?.recentSearchHash; const searchKey = recentSearchHash !== undefined ? Object.values(suggestedSearches).find((s) => s.recentSearchHash === recentSearchHash)?.key : undefined; - // Ref to read currentSearchResults in the search effect without adding it to deps. - // This prevents re-triggering when data arrives — search should fire once per query change. - const currentSearchResultsRef = useRef(currentSearchResults); - useLayoutEffect(() => { - currentSearchResultsRef.current = currentSearchResults; - }, [currentSearchResults]); - // useCallback is required here because useFocusEffect (React Navigation external API) compares callback references. // React Compiler cannot optimize this — it doesn't know useFocusEffect's internal semantics. const syncContextWithRoute = useCallback(() => { @@ -55,15 +48,13 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { // Fire search() when the query changes (hash/searchKey). This runs at the page level so the // API request starts in parallel with the skeleton, before Search mounts its 14+ useOnyx hooks. - // Uses a ref for currentSearchResults to avoid re-triggering on every data update. - // The search() action has an internal guard (isLoading check) that prevents duplicate calls, - // so if Search's internal handleSearch effect also fires, only one API request will proceed. + // currentSearchResults is intentionally read but not in deps — search should fire once per + // query change, not re-trigger on every data update from Onyx. useEffect(() => { if (!queryJSON || hash === undefined || shouldUseLiveData || isOffline) { return; } - const results = currentSearchResultsRef.current; - if (isSearchDataLoaded(results, queryJSON) || results?.search?.isLoading) { + if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) { return; } search({queryJSON, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); From 6fe3bff79fcb94188a00d1f2b2270e7337f084c6 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Thu, 12 Mar 2026 16:18:48 +0100 Subject: [PATCH 10/14] Update Mobile-Expensify to remove it from files changes --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 5430cbd14c0a9..3d8daef358359 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 5430cbd14c0a9fd2148be6f8b54844680d0f178f +Subproject commit 3d8daef3583592dc43bb6eb05ff46377aba2c833 From db145a19d15d2ffdcc0ddc353efa824eaab56afb Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 13 Mar 2026 18:53:54 +0100 Subject: [PATCH 11/14] PR fixes --- src/hooks/useSearchLoadingState.ts | 4 +--- src/hooks/useSearchPageSetup.ts | 5 +++-- src/pages/Search/SearchPageNarrow.tsx | 9 ++++++--- src/pages/Search/SearchPageWide.tsx | 6 +++++- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/hooks/useSearchLoadingState.ts b/src/hooks/useSearchLoadingState.ts index ea6ba5f60485a..51c86d5c21465 100644 --- a/src/hooks/useSearchLoadingState.ts +++ b/src/hooks/useSearchLoadingState.ts @@ -1,6 +1,5 @@ import {useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; -import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import type {SearchResults} from '@src/types/onyx'; import useNetwork from './useNetwork'; @@ -21,13 +20,12 @@ function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined, searchRes return false; } - const isDataLoaded = isSearchDataLoaded(searchResults, queryJSON); const hasNoData = searchResults?.data === undefined; // Show page-level skeleton ONLY when no data has ever arrived for this query. // Once data arrives (even empty []), Search mounts and handles its own // loading/empty states internally via shouldShowLoadingState. - return !isDataLoaded && hasNoData; + return hasNoData; } export default useSearchLoadingState; diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts index d6f7ea66dbbf2..461bceacaf5ee 100644 --- a/src/hooks/useSearchPageSetup.ts +++ b/src/hooks/useSearchPageSetup.ts @@ -6,6 +6,7 @@ import {openSearch, search} from '@libs/actions/Search'; import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import useNetwork from './useNetwork'; import usePrevious from './usePrevious'; +import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals'; /** * Handles page-level setup for Search that must happen before the Search component mounts: @@ -21,6 +22,7 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { const {shouldUseLiveData, currentSearchResults, currentSearchKey} = useSearchStateContext(); const hash = queryJSON?.hash; + const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, hash, true); // Clear selected transactions when navigating to a different search query const clearOnHashChange = useCallback(() => { @@ -47,8 +49,7 @@ function useSearchPageSetup(queryJSON: SearchQueryJSON | undefined) { if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) { return; } - search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); - // eslint-disable-next-line react-hooks/exhaustive-deps + search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false}); }, [hash, isOffline, shouldUseLiveData, queryJSON]); useEffect(() => { diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index 55c43056a32d9..aa60df390f5a5 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -13,7 +13,7 @@ import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import Search from '@components/Search'; -import {useSearchActionsContext} from '@components/Search/SearchContext'; +import {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchFiltersBar from '@components/Search/SearchPageHeader/SearchFiltersBar'; @@ -65,6 +65,7 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {clearSelectedTransactions} = useSearchActionsContext(); + const {shouldUseLiveData} = useSearchStateContext(); const [searchRouterListVisible, setSearchRouterListVisible] = useState(false); const {isOffline} = useNetwork(); const shouldShowLoadingBarForReports = useLoadingBarVisibility(); @@ -165,7 +166,7 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable ); } - const isDataLoaded = isSearchDataLoaded(searchResults, queryJSON); + const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON); const shouldShowLoadingState = !isOffline && (!isDataLoaded || !!metadata?.isLoading); return ( @@ -253,9 +254,11 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable reasonAttributes={{ context: 'SearchPage', isOffline, - isDataLoaded: isSearchDataLoaded(searchResults, queryJSON), + isDataLoaded, isSearchLoading: !!searchResults?.search?.isLoading, hasEmptyData: Array.isArray(searchResults?.data) && searchResults?.data.length === 0, + hasPendingResponse: searchRequestResponseStatusCode === null, + shouldUseLiveData, }} /> ) : ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 534d164009c87..be0321d4f3207 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -7,6 +7,7 @@ import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import Search from '@components/Search'; +import {useSearchStateContext} from '@components/Search/SearchContext'; import SearchLoadingSkeleton from '@components/Search/SearchLoadingSkeleton'; import SearchPageFooter from '@components/Search/SearchPageFooter'; import SearchFiltersBar from '@components/Search/SearchPageHeader/SearchFiltersBar'; @@ -54,6 +55,7 @@ function SearchPageWide({ const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const styles = useThemeStyles(); const {isOffline} = useNetwork(); + const {shouldUseLiveData} = useSearchStateContext(); const {saveScrollOffset} = useContext(ScrollOffsetContext); const receiptDropTargetRef = useRef(null); @@ -112,9 +114,11 @@ function SearchPageWide({ reasonAttributes={{ context: 'SearchPage', isOffline, - isDataLoaded: isSearchDataLoaded(searchResults, queryJSON), + isDataLoaded: shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON), isSearchLoading: !!searchResults?.search?.isLoading, hasEmptyData: Array.isArray(searchResults?.data) && searchResults?.data.length === 0, + hasPendingResponse: searchRequestResponseStatusCode === null, + shouldUseLiveData, }} /> ) : ( From 377eea4fde0753384d9a273865db4bd4db5947cf Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Mon, 16 Mar 2026 12:39:12 +0100 Subject: [PATCH 12/14] PR fixes --- src/pages/Search/SearchPageNarrow.tsx | 1 + src/pages/Search/SearchPageWide.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/pages/Search/SearchPageNarrow.tsx b/src/pages/Search/SearchPageNarrow.tsx index aa60df390f5a5..85553a20775a9 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -257,6 +257,7 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable isDataLoaded, isSearchLoading: !!searchResults?.search?.isLoading, hasEmptyData: Array.isArray(searchResults?.data) && searchResults?.data.length === 0, + hasErrors: Object.keys(searchResults?.errors ?? {}).length > 0 && !isOffline, hasPendingResponse: searchRequestResponseStatusCode === null, shouldUseLiveData, }} diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index be0321d4f3207..0b0287030b540 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -117,6 +117,7 @@ function SearchPageWide({ isDataLoaded: shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON), isSearchLoading: !!searchResults?.search?.isLoading, hasEmptyData: Array.isArray(searchResults?.data) && searchResults?.data.length === 0, + hasErrors: Object.keys(searchResults?.errors ?? {}).length > 0 && !isOffline, hasPendingResponse: searchRequestResponseStatusCode === null, shouldUseLiveData, }} From cea9a172d800c6f5d942f1382155707e1a3bc82b Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Tue, 17 Mar 2026 17:32:59 +0100 Subject: [PATCH 13/14] PR fix --- src/hooks/useSearchLoadingState.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/hooks/useSearchLoadingState.ts b/src/hooks/useSearchLoadingState.ts index 51c86d5c21465..4757426c8bd1e 100644 --- a/src/hooks/useSearchLoadingState.ts +++ b/src/hooks/useSearchLoadingState.ts @@ -1,31 +1,35 @@ import {useSearchStateContext} from '@components/Search/SearchContext'; import type {SearchQueryJSON} from '@components/Search/types'; +import {getValidGroupBy} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {SearchResults} from '@src/types/onyx'; import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; /** * Computes whether the search page should show a loading skeleton. * Accepts searchResults from the caller (which may include a sorting fallback) * rather than reading raw context data, so that sorting doesn't trigger a skeleton flash. - * - * Note: This hook intentionally does NOT check isCardFeedsLoading. Card feed loading is handled - * internally by the Search component's shouldShowLoadingState — blocking Search from mounting - * would prevent the API call from firing and create a deadlock. */ function useSearchLoadingState(queryJSON: SearchQueryJSON | undefined, searchResults: SearchResults | undefined): boolean { const {isOffline} = useNetwork(); const {shouldUseLiveData} = useSearchStateContext(); + const [, cardFeedsResult] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER); if (shouldUseLiveData || isOffline || !queryJSON) { return false; } const hasNoData = searchResults?.data === undefined; + const validGroupBy = getValidGroupBy(queryJSON.groupBy); + const isCardFeedsLoading = validGroupBy === CONST.SEARCH.GROUP_BY.CARD && cardFeedsResult?.status === 'loading'; - // Show page-level skeleton ONLY when no data has ever arrived for this query. + // Show page-level skeleton when no data has ever arrived for this query, + // or when card feeds are still loading for card-grouped searches. // Once data arrives (even empty []), Search mounts and handles its own // loading/empty states internally via shouldShowLoadingState. - return hasNoData; + return hasNoData || isCardFeedsLoading; } export default useSearchLoadingState; From c853265e889327f1787949601404ddd400b1ec17 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Wed, 18 Mar 2026 10:22:32 +0100 Subject: [PATCH 14/14] PR fix --- src/libs/actions/Search.ts | 61 ++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index e3284de14600a..4115028df5bbe 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -429,6 +429,11 @@ function openSearchPage({includePartiallySetupBankAccounts}: OpenSearchPageParam API.read(READ_COMMANDS.OPEN_SEARCH_PAGE, {includePartiallySetupBankAccounts}); } +// Tracks in-flight search requests by hash+offset to prevent duplicate API calls +// when both page-level (useSearchPageSetup) and Search-internal (handleSearch) effects +// fire for the same query. Cleared when the request completes. +const inFlightSearchRequests = new Set(); + let shouldPreventSearchAPI = false; function handlePreventSearchAPI(hash: number | undefined) { if (typeof hash === 'undefined') { @@ -472,6 +477,12 @@ function search({ return; } + const dedupeKey = `${queryJSON.hash}_${offset ?? 0}`; + if (inFlightSearchRequests.has(dedupeKey)) { + return; + } + inFlightSearchRequests.add(dedupeKey); + const {optimisticData, finallyData, failureData} = getOnyxLoadingData(queryJSON.hash, queryJSON, offset, isOffline, true, shouldCalculateTotals); const {flatFilters, limit, ...queryJSONWithoutFlatFilters} = queryJSON; const query = { @@ -493,36 +504,40 @@ function search({ return waitForWrites(READ_COMMANDS.SEARCH).then(() => { // eslint-disable-next-line rulesdir/no-api-side-effects-method - return API.makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}).then((result) => { - const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; - const reports = Object.keys(response?.data ?? {}) - .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) - .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, '')); - if (response?.search?.offset) { - // Indicates that search results are extended from the Report view (with navigation between reports), - // using previous results to enable correct counter behavior. - if (prevReportsLength) { + return API.makeRequestWithSideEffects(READ_COMMANDS.SEARCH, {hash: queryJSON.hash, jsonQuery}, {optimisticData, finallyData, failureData}) + .then((result) => { + const response = result?.onyxData?.[0]?.value as OnyxSearchResponse; + const reports = Object.keys(response?.data ?? {}) + .filter((key) => key.startsWith(ONYXKEYS.COLLECTION.REPORT)) + .map((key) => key.replace(ONYXKEYS.COLLECTION.REPORT, '')); + if (response?.search?.offset) { + // Indicates that search results are extended from the Report view (with navigation between reports), + // using previous results to enable correct counter behavior. + if (prevReportsLength) { + saveLastSearchParams({ + queryJSON, + offset, + hasMoreResults: !!response?.search?.hasMoreResults, + previousLengthOfResults: prevReportsLength, + allowPostSearchRecount: false, + }); + } + } else { + // Applies to all searches from the Search View saveLastSearchParams({ queryJSON, offset, hasMoreResults: !!response?.search?.hasMoreResults, - previousLengthOfResults: prevReportsLength, - allowPostSearchRecount: false, + previousLengthOfResults: reports.length, + allowPostSearchRecount: true, }); } - } else { - // Applies to all searches from the Search View - saveLastSearchParams({ - queryJSON, - offset, - hasMoreResults: !!response?.search?.hasMoreResults, - previousLengthOfResults: reports.length, - allowPostSearchRecount: true, - }); - } - return result?.jsonCode; - }); + return result?.jsonCode; + }) + .finally(() => { + inFlightSearchRequests.delete(dedupeKey); + }); }); }