diff --git a/src/components/Search/SearchLoadingSkeleton.tsx b/src/components/Search/SearchLoadingSkeleton.tsx new file mode 100644 index 0000000000000..4dc4bbf9f2c32 --- /dev/null +++ b/src/components/Search/SearchLoadingSkeleton.tsx @@ -0,0 +1,36 @@ +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 type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; +import CONST from '@src/CONST'; + +type SearchLoadingSkeletonProps = { + containerStyle?: StyleProp; + reasonAttributes?: SkeletonSpanReasonAttributes; +}; + +function SearchLoadingSkeleton({containerStyle, reasonAttributes}: 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 ddbbf382afaf9..b05a18987bd43 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'; @@ -36,7 +36,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'; @@ -50,6 +50,7 @@ import { getListItem, getSections, getSortedSections, + getValidGroupBy, getWideAmountIndicators, isGroupedItemArray, isReportActionListItemType, @@ -228,7 +229,6 @@ function Search({ const navigation = useNavigation>(); const isFocused = useIsFocused(); const {markReportIDAsExpense} = useWideRHPActions(); - const { currentSearchHash, currentSearchKey, @@ -240,7 +240,6 @@ function Search({ shouldUseLiveData, suggestedSearches, } = useSearchStateContext(); - const {setSelectedTransactions, clearSelectedTransactions, setShouldShowFiltersBarLoading, setShouldShowSelectAllMatchingItems, selectAllMatchingItems, setShouldResetSearchQuery} = useSearchActionsContext(); const [offset, setOffset] = useState(0); @@ -305,7 +304,7 @@ function Search({ } }, [onDEWModalOpen, showConfirmModal, translate]); - 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); @@ -353,17 +352,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, @@ -448,21 +436,6 @@ function Search({ const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; - const loadingSkeletonReasonAttributes = useMemo( - () => ({ - context: 'Search', - isOffline, - isDataLoaded, - isCardFeedsLoading, - isSearchLoading: !!searchResults?.search?.isLoading, - hasEmptyData: Array.isArray(searchResults?.data) && searchResults?.data.length === 0, - hasErrors, - hasPendingResponse: searchRequestResponseStatusCode === null, - shouldUseLiveData, - }), - [isOffline, isDataLoaded, isCardFeedsLoading, searchResults?.search?.isLoading, searchResults?.data, hasErrors, searchRequestResponseStatusCode, shouldUseLiveData], - ); - const loadMoreSkeletonReasonAttributes = useMemo( () => ({ context: 'Search.ListFooter', @@ -1325,11 +1298,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}); @@ -1354,23 +1322,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..4757426c8bd1e --- /dev/null +++ b/src/hooks/useSearchLoadingState.ts @@ -0,0 +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. + */ +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 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 || isCardFeedsLoading; +} + +export default useSearchLoadingState; diff --git a/src/hooks/useSearchPageSetup.ts b/src/hooks/useSearchPageSetup.ts new file mode 100644 index 0000000000000..461bceacaf5ee --- /dev/null +++ b/src/hooks/useSearchPageSetup.ts @@ -0,0 +1,67 @@ +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'; +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: + * - Clears selected transactions when the query changes + * - 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 {clearSelectedTransactions} = useSearchActionsContext(); + 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(() => { + if (hash === undefined) { + return; + } + clearSelectedTransactions(hash); + }, [hash, clearSelectedTransactions]); + + useFocusEffect(clearOnHashChange); + + // useEffect supplements useFocusEffect: it handles both the initial mount + // and cases where route params change without a navigation event (e.g. sorting). + useEffect(clearOnHashChange, [clearOnHashChange]); + + // Fire search() when the query changes (hash). This runs at the page level so the + // API request starts in parallel with the skeleton, before Search mounts its 14+ useOnyx hooks. + // 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; + } + if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) { + return; + } + search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false}); + }, [hash, isOffline, shouldUseLiveData, queryJSON]); + + useEffect(() => { + 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 40584fc5538bf..cb590c3bc3b0d 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3939,6 +3939,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: @@ -4779,6 +4783,7 @@ export { shouldShowEmptyState, compareValues, isSearchDataLoaded, + getValidGroupBy, getStatusOptions, getTypeOptions, getGroupByOptions, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 1954fff98bdc1..d0eda6bf35458 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -435,6 +435,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') { @@ -478,6 +483,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 = { @@ -499,36 +510,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); + }); }); } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 48dbbf8125705..acf618f96cae7 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -9,6 +9,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchPageSetup from '@hooks/useSearchPageSetup'; import useSearchShouldCalculateTotals from '@hooks/useSearchShouldCalculateTotals'; import useThemeStyles from '@hooks/useThemeStyles'; import {searchInServer} from '@libs/actions/Report'; @@ -35,6 +36,7 @@ function SearchPage({route}: SearchPageProps) { const lastNonEmptySearchResults = useRef(undefined); useConfirmReadyToOpenApp(); + useSearchPageSetup(currentSearchQueryJSON); useEffect(() => { if (!currentSearchResults?.search?.type) { @@ -54,7 +56,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 310437fb88d3e..85553a20775a9 100644 --- a/src/pages/Search/SearchPageNarrow.tsx +++ b/src/pages/Search/SearchPageNarrow.tsx @@ -12,8 +12,9 @@ import TopBar from '@components/Navigation/TopBar'; import ReceiptScanDropZone from '@components/ReceiptScanDropZone'; import ScreenWrapper from '@components/ScreenWrapper'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; -import DeferredSearch from '@components/Search/DeferredSearch'; -import {useSearchActionsContext} from '@components/Search/SearchContext'; +import Search from '@components/Search'; +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'; import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; @@ -24,6 +25,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; 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'; @@ -56,12 +58,14 @@ type SearchPageNarrowProps = { }; function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnabled, metadata, footerData, shouldShowFooter}: SearchPageNarrowProps) { + const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {windowHeight} = useWindowDimensions(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {clearSelectedTransactions} = useSearchActionsContext(); + const {shouldUseLiveData} = useSearchStateContext(); const [searchRouterListVisible, setSearchRouterListVisible] = useState(false); const {isOffline} = useNetwork(); const shouldShowLoadingBarForReports = useLoadingBarVisibility(); @@ -162,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 ( @@ -244,15 +248,32 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable )} {!searchRouterListVisible && ( - + {shouldShowLoadingSkeleton ? ( + 0 && !isOffline, + hasPendingResponse: searchRequestResponseStatusCode === null, + shouldUseLiveData, + }} + /> + ) : ( + + )} )} {shouldShowFooter && !searchRouterListVisible && ( diff --git a/src/pages/Search/SearchPageWide.tsx b/src/pages/Search/SearchPageWide.tsx index 1e858ecb38669..0b0287030b540 100644 --- a/src/pages/Search/SearchPageWide.tsx +++ b/src/pages/Search/SearchPageWide.tsx @@ -7,14 +7,19 @@ 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'; import SearchPageHeader from '@components/Search/SearchPageHeader/SearchPageHeader'; import type {SearchParams, SearchQueryJSON} from '@components/Search/types'; +import useNetwork from '@hooks/useNetwork'; +import useSearchLoadingState from '@hooks/useSearchLoadingState'; import useThemeStyles from '@hooks/useThemeStyles'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {isSearchDataLoaded} from '@libs/SearchUIUtils'; import Navigation from '@navigation/Navigation'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -47,7 +52,10 @@ function SearchPageWide({ route, shouldShowFooter, }: SearchPageWideProps) { + const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON, searchResults); const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const {shouldUseLiveData} = useSearchStateContext(); const {saveScrollOffset} = useContext(ScrollOffsetContext); const receiptDropTargetRef = useRef(null); @@ -100,16 +108,32 @@ function SearchPageWide({ queryJSON={queryJSON} isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} /> - + {shouldShowLoadingSkeleton ? ( + 0 && !isOffline, + hasPendingResponse: searchRequestResponseStatusCode === null, + shouldUseLiveData, + }} + /> + ) : ( + + )} {shouldShowFooter && (