-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Refactor search functionality to improve loading state handling #83917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5af0782
a419593
f49723d
33f9840
87d6f70
803f2e6
7075730
c389d60
e0c5f7d
8e8480d
8067359
833048d
da450a5
92140a6
89dc6da
6fe3bff
c4ad5e2
db145a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<ViewStyle>; | ||
| reasonAttributes?: SkeletonSpanReasonAttributes; | ||
| }; | ||
|
|
||
| function SearchLoadingSkeleton({containerStyle, reasonAttributes}: SearchLoadingSkeletonProps) { | ||
| const styles = useThemeStyles(); | ||
|
|
||
| return ( | ||
| <Animated.View | ||
| entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} | ||
| exiting={FadeOut.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} | ||
| style={[styles.flex1]} | ||
| onLayout={() => { | ||
| endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false}); | ||
| }} | ||
| > | ||
| <SearchRowSkeleton | ||
| shouldAnimate | ||
| containerStyle={containerStyle} | ||
| reasonAttributes={reasonAttributes} | ||
| /> | ||
| </Animated.View> | ||
| ); | ||
| } | ||
|
|
||
| export default SearchLoadingSkeleton; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -227,7 +228,6 @@ function Search({ | |
| const navigation = useNavigation<PlatformStackNavigationProp<SearchFullscreenNavigatorParamList>>(); | ||
| const isFocused = useIsFocused(); | ||
| const {markReportIDAsExpense} = useWideRHPActions(); | ||
|
|
||
| const { | ||
| currentSearchHash, | ||
| currentSearchKey, | ||
|
|
@@ -239,7 +239,6 @@ function Search({ | |
| shouldUseLiveData, | ||
| suggestedSearches, | ||
| } = useSearchStateContext(); | ||
|
|
||
| const {setSelectedTransactions, clearSelectedTransactions, setShouldShowFiltersBarLoading, setShouldShowSelectAllMatchingItems, selectAllMatchingItems, setShouldResetSearchQuery} = | ||
| useSearchActionsContext(); | ||
| const [offset, setOffset] = useState(0); | ||
|
|
@@ -304,7 +303,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); | ||
|
|
||
|
|
@@ -352,17 +351,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, | ||
|
|
@@ -393,21 +381,6 @@ function Search({ | |
|
|
||
| const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we take
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately, no - these serve different purposes. useSearchLoadingState gates whether the Search component should mount at all (page-level skeleton vs component). The internal shouldShowLoadingState in Search/index.tsx controls post-mount behavior (loading bar in TopBar, "load more" skeleton, telemetry warm/cold attribute, etc.) and has additional conditions like hasErrors && searchRequestResponseStatusCode === null that are only relevant once Search is mounted. Merging them would conflate two different concerns. |
||
|
|
||
| const loadingSkeletonReasonAttributes = useMemo<SkeletonSpanReasonAttributes>( | ||
| () => ({ | ||
| 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<SkeletonSpanReasonAttributes>( | ||
| () => ({ | ||
| context: 'Search.ListFooter', | ||
|
|
@@ -1268,11 +1241,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}); | ||
|
|
@@ -1297,23 +1265,6 @@ function Search({ | |
| }, [shouldShowLoadingState]), | ||
| ); | ||
|
|
||
| if (shouldShowLoadingState) { | ||
| return ( | ||
| <Animated.View | ||
| entering={FadeIn.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} | ||
| exiting={FadeOut.duration(CONST.SEARCH.ANIMATION.FADE_DURATION)} | ||
| style={[styles.flex1]} | ||
| onLayout={onLayoutSkeleton} | ||
| > | ||
| <SearchRowSkeleton | ||
| shouldAnimate | ||
| containerStyle={shouldUseNarrowLayout ? styles.searchListContentContainerStyles : styles.mt3} | ||
| reasonAttributes={loadingSkeletonReasonAttributes} | ||
| /> | ||
| </Animated.View> | ||
| ); | ||
| } | ||
|
|
||
| if (searchResults === undefined) { | ||
| Log.alert('[Search] Undefined search type'); | ||
| cancelNavigationSpans(); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import {useSearchStateContext} from '@components/Search/SearchContext'; | ||
| import type {SearchQueryJSON} from '@components/Search/types'; | ||
| import type {SearchResults} from '@src/types/onyx'; | ||
| import useNetwork from './useNetwork'; | ||
|
|
||
| /** | ||
| * 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(); | ||
|
|
||
| if (shouldUseLiveData || isOffline || !queryJSON) { | ||
| return false; | ||
| } | ||
|
|
||
| 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 hasNoData; | ||
| } | ||
|
|
||
| export default useSearchLoadingState; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❌ PERF-12 (docs)
Wrap const syncContextWithRoute = useCallback(() => {
if (hash === undefined || recentSearchHash === undefined || !queryJSON) {
return;
}
clearSelectedTransactions(hash);
setCurrentSearchHashAndKey(hash, recentSearchHash, searchKey);
setCurrentSearchQueryJSON(queryJSON);
}, [hash, recentSearchHash, searchKey, clearSelectedTransactions, setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, queryJSON]);Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency. |
||
| return; | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❌ PERF-9 (docs)
This is directly related to the missing Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency. |
||
| if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) { | ||
| return; | ||
| } | ||
| search({queryJSON, searchKey: currentSearchKey, offset: 0, shouldCalculateTotals, isLoading: false}); | ||
| }, [hash, isOffline, shouldUseLiveData, queryJSON]); | ||
|
Check warning on line 53 in src/hooks/useSearchPageSetup.ts
|
||
|
|
||
| useEffect(() => { | ||
| openSearch({includePartiallySetupBankAccounts: true}); | ||
| }, []); | ||
|
|
||
| useEffect(() => { | ||
| if (!prevIsOffline || isOffline) { | ||
| return; | ||
| } | ||
| openSearch({includePartiallySetupBankAccounts: true}); | ||
| }, [isOffline, prevIsOffline]); | ||
| } | ||
|
|
||
| export default useSearchPageSetup; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should also remove the skeleton logic from SearchList since we now do this up the tree
App/src/components/Search/index.tsx
Lines 1210 to 1224 in eed56d0