Refactor search functionality to improve loading state handling#83917
Conversation
| @@ -141,6 +160,10 @@ function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnable | |||
| return () => removeRouteKey(route.key); | |||
| }, [addRouteKey, removeRouteKey, route.key, searchRouterListVisible]); | |||
|
|
|||
| const onLayoutSkeleton = () => { | |||
| endSpan(CONST.TELEMETRY.SPAN_ON_LAYOUT_SKELETON_REPORTS); | |||
There was a problem hiding this comment.
We changed this span. We now track a single span. We should also remove it from the search page, since we
App/src/components/Search/index.tsx
Lines 1187 to 1190 in eed56d0
| useEffect(() => { | ||
| openSearch({includePartiallySetupBankAccounts: true}); | ||
| }, []); | ||
|
|
There was a problem hiding this comment.
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
src/pages/Search/SearchPage.tsx
Outdated
|
|
||
| useConfirmReadyToOpenApp(); | ||
|
|
||
| // Set the search context hash at the page level so the Onyx subscription | ||
| // in SearchContext points to the correct snapshot key even before Search mounts. | ||
| useEffect(() => { |
There was a problem hiding this comment.
these should go into a named hook(s), do not inline effects
src/pages/Search/SearchPage.tsx
Outdated
| confirmPayment={stableOnBulkPaySelected} | ||
| latestBankItems={latestBankItems} | ||
| shouldShowFooter={shouldShowFooter} | ||
| shouldShowLoadingState={shouldShowLoadingState} |
There was a problem hiding this comment.
this should be flagged by the AI reviewer, but for the sake of it - let's compose this skeleton in. there's no need to prop drill anything here.
src/pages/Search/SearchPage.tsx
Outdated
| isMobileSelectionModeEnabled={isMobileSelectionModeEnabled} | ||
| shouldShowLoadingState={shouldShowLoadingState} |
There was a problem hiding this comment.
same here, do not drill this property down since it's just a UI lever. let's think how we can group these components better inside/alongside each other so the loading state can be easily declared (which you proved calculating its state there).
src/pages/Search/SearchPageWide.tsx
Outdated
| PDFValidationComponent, | ||
| ErrorModal, | ||
| shouldShowFooter, | ||
| shouldShowLoadingState, |
There was a problem hiding this comment.
commented across the PR on this prop drilling violation of the review rules (see .claude/skills/coding-standards)
f076b0e to
5af0782
Compare
…ymonzalarski/search/move-skeleton-to-top-level-search-page # Conflicts: # src/components/Search/index.tsx # src/pages/Search/SearchPage.tsx
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f49723dbb9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
src/hooks/useSearchLoadingState.ts
Outdated
| const isDataLoaded = isSearchDataLoaded(currentSearchResults, queryJSON); | ||
| const isLoadingWithNoData = !!currentSearchResults?.search?.isLoading && Array.isArray(currentSearchResults?.data) && currentSearchResults.data.length === 0; |
There was a problem hiding this comment.
Base skeleton state on rendered results during sorting
This hook computes loading from currentSearchResults, but during a sort the page intentionally keeps rendering lastNonEmptySearchResults to avoid a full-page flash (see SearchPage's isSorting fallback). After the hash changes, currentSearchResults points to the new empty snapshot, so !isDataLoaded becomes true and the page swaps the existing table for the full skeleton until the response returns, regressing the “no skeleton flash on sort” behavior.
Useful? React with 👍 / 👎.
src/hooks/useSearchPageSetup.ts
Outdated
| import useNetwork from './useNetwork'; | ||
| import usePrevious from './usePrevious'; | ||
|
|
||
| let didOpenSearch = false; |
There was a problem hiding this comment.
Avoid global openSearch suppression across sessions
Using a module-level didOpenSearch flag means openSearch() runs only once per JS runtime, not once per Search page mount. After logout/login or account switching without restarting the app, this effect will be skipped and search bootstrap data (including bank-account payload requested by openSearch) will not be refreshed unless connectivity toggles, which can leave search-dependent data stale or missing for the new session.
Useful? React with 👍 / 👎.
|
@luacmartins @adhorodyski Hey, I've updated the PR |
|
Does this need c+ review @luacmartins ? |
| import usePrevious from './usePrevious'; | ||
|
|
||
| let didOpenSearch = false; | ||
|
|
There was a problem hiding this comment.
❌ PERF-12 (docs)
The module-level let didOpenSearch = false flag is never reset. Once set to true, it persists for the lifetime of the JavaScript context. This means if a user logs out and logs back in, openSearch() will never fire again because the flag remains true. Unlike the original code in Search/index.tsx which called openSearch() in a straightforward useEffect([], []) on every mount of the Search component, this module-level guard permanently prevents re-execution.
Consider resetting the flag when the hook's host component unmounts, or use a useRef instead of a module-level variable so the guard is scoped to the component instance:
const didOpenSearch = useRef(false);
useEffect(() => {
if (didOpenSearch.current) {
return;
}
didOpenSearch.current = true;
openSearch({includePartiallySetupBankAccounts: true});
}, []);Alternatively, if a module-level flag is intentional, add cleanup to reset it on unmount:
useEffect(() => {
return () => { didOpenSearch = false; };
}, []);Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
| useEffect(syncContextWithRoute, [syncContextWithRoute]); | ||
|
|
||
| useEffect(() => { | ||
| if (!queryJSON || hash === undefined || shouldUseLiveData || isOffline) { |
There was a problem hiding this comment.
❌ PERF-12 (docs)
useFocusEffect subscribes to navigation focus/blur events internally. When the callback passed to it changes on every render (because syncContextWithRoute is not wrapped in useCallback), useFocusEffect tears down and re-creates its internal useEffect subscription on every render. This causes repeated subscribe/unsubscribe cycles to the navigation event listeners, which is wasteful and could lead to subtle timing bugs.
Wrap syncContextWithRoute in useCallback with the appropriate dependencies, matching how the original code used useCallback for clearTransactionsAndSetHashAndKey:
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.
| useEffect(() => { | ||
| if (!queryJSON || hash === undefined || shouldUseLiveData || isOffline) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
❌ PERF-9 (docs)
useEffect(syncContextWithRoute, [syncContextWithRoute]) will fire on every render because syncContextWithRoute is an inline function that creates a new reference each render. This causes clearSelectedTransactions, setCurrentSearchHashAndKey, and setCurrentSearchQueryJSON to be called on every single render of the host component, which is excessive and could cause unnecessary state updates and re-renders throughout the search context consumers.
This is directly related to the missing useCallback on syncContextWithRoute. Once that function is memoized with useCallback, this effect will only fire when the actual dependencies change. However, also consider whether this separate useEffect is even necessary alongside the useFocusEffect -- the original code in Search/index.tsx had a similar pair but with an eslint-disable comment explaining it was for the mount case when the screen is not focused (e.g., page reload with RHP open).
Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
|
@FitseTLT yes! Please prioritize this review when you're online |
JmillsExpensify
left a comment
There was a problem hiding this comment.
No product review required.
on it |
|
Messaged the prettier diff in Slack |
|
@FitseTLT please prioritize this review when you're online |
I am reviewing it |
| @@ -408,21 +375,6 @@ function Search({ | |||
| isCardFeedsLoading); | |||
| const shouldShowLoadingMoreItems = !shouldShowLoadingState && searchResults?.search?.isLoading && searchResults?.search?.offset > 0; | |||
There was a problem hiding this comment.
Can we take shouldShowLoadingState here in Search from useSearchLoading hook?
There was a problem hiding this comment.
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.
src/hooks/useSearchLoadingState.ts
Outdated
| const hasNoData = currentSearchResults?.data === undefined; | ||
|
|
||
| return (!isDataLoaded && hasNoData) || isLoadingWithNoData || isCardFeedsLoading; | ||
| } |
There was a problem hiding this comment.
- BTW adding
&& hasNoDatato!isDataLoadedwill make!isDataLoadedlogic unnecessary because the inequality between searchResult.search.type and queryJson.type (etc) insideisSearchDataLoadedwill now have no effect in the logic. - Replacing
searchResultswithcurrentSearchResultsto calculate isDataLoaded has now made the loading skeleton to be shown when we changing sort.
2026-03-06.22-25-38.mp4
I also think that I see more delay before the loading skeleton is shown compared to staging when changing search presets.
2026-03-06.23-05-38.mp4
There was a problem hiding this comment.
Both addressed:
- You're right that !isDataLoaded is technically redundant when combined with hasNoData - if data === undefined, isDataLoaded is always false. We kept it for readability but the effective condition is just hasNoData. The hook now returns !isDataLoaded && hasNoData.
- The sorting regression is fixed - the hook now accepts searchResults from the caller prop (which includes the lastNonEmptySearchResults sorting fallback from SearchPage) instead of reading currentSearchResults from context. During sorting, the fallback keeps data defined, so the skeleton stays hidden.
There was a problem hiding this comment.
Regarding (1) I think you need to recheck. Because what I commented about was (!isDataLoaded && hasNoData) but u instead removed || isLoadingWithNoData || isCardFeedsLoading; 😄
There was a problem hiding this comment.
waiting for your response here @szymonzalarski98
There was a problem hiding this comment.
You're right, I misread your comment initially. Fixed - removed the redundant !isDataLoaded check. The hook now returns just hasNoData (searchResults?.data === undefined), since when data is undefined, isDataLoaded is always false anyway.
There was a problem hiding this comment.
@szymonzalarski98 why can't we stick with previous logic to avoid regression unless it an intentional change like we did for error case?
I still see differences with this
App/src/components/Search/index.tsx
Lines 367 to 380 in db145a1
the way we calculated isDataLoaded didn't only check for existence to avoid race condition as depicted in the comment and also there were two cases you omitted here
(!!searchResults?.search.isLoading && Array.isArray(searchResults?.data) && searchResults?.data.length === 0) ||
and
isCardFeedsLoading
src/hooks/useSearchPageSetup.ts
Outdated
| if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) { | ||
| return; | ||
| } | ||
| search({queryJSON, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false}); |
There was a problem hiding this comment.
Where is this search coming from? I thought we were not adding a new logic here.
There was a problem hiding this comment.
This is not new logic - it's the same search() call that was previously inside Search/index.tsx (the mount effect that fires the initial API call). We moved it to page level so that the API request starts in parallel with the skeleton rendering, rather than waiting for Search to mount (which requires 14+ useOnyx hooks to initialize first). The guards are identical: skip if data is already loaded or if a request is already in flight (currentSearchResults?.search?.isLoading).
There was a problem hiding this comment.
Why is shouldCalculateTotals passed as false? Doesn't it depend on the searchHash like here
There was a problem hiding this comment.
Good catch. The page-level search() call is a preliminary request to get data loading before Search mounts. We pass shouldCalculateTotals: false because the initial request focuses on getting data as fast as possible. Search's internal handleSearch effect has retry logic (via shouldRetrySearchWithTotalsOrGroupedRef at line 557-578) - after its first mount, if totals are needed, it re-queries with shouldCalculateTotals: true. This avoids an unnecessary initial totals calculation that would be recalculated anyway. However, if you'd prefer the correct value from the start, I can wire in useSearchShouldCalculateTotals - let me know.
There was a problem hiding this comment.
I'm not sure why we'd retrigger a Search API command here. Let's just remove this logic and pass the correct value to being with.
There was a problem hiding this comment.
The page-level search() call is actually required in this architecture - it's not a duplicate. Here's why:
In baseline, the skeleton was rendered inside Search component. Search always mounted, fired the API call via handleSearch, and showed the skeleton internally while waiting for data.
In this PR, the skeleton is rendered outside Search at the page level. When data === undefined, the page shows the skeleton instead of mounting Search. This means Search's handleSearch effect never fires, the API call never happens, and we get infinite loading.
The page-level search() call breaks this deadlock - it fires the API request while the skeleton is visible, so that when data arrives, Search can mount.
There was a problem hiding this comment.
I agree it's needed, but why do we trigger it with shouldCalculateTotals: false if we know that it should be true given the search query? We should just pass the correct value to begin with
There was a problem hiding this comment.
Done - wired in useSearchShouldCalculateTotals(currentSearchKey, hash, true) so the page-level search() call passes the correct value from the start.
| }; | ||
|
|
||
| function SearchPageNarrow({queryJSON, searchResults, isMobileSelectionModeEnabled, metadata, footerData, shouldShowFooter}: SearchPageNarrowProps) { | ||
| const shouldShowLoadingSkeleton = useSearchLoadingState(queryJSON); |
There was a problem hiding this comment.
Will using shouldShowLoadingSkeleton in SearchPage and passing it as a prop for both improve the timing even more?
There was a problem hiding this comment.
The timing difference would be negligible (one React component level ≈ <1ms). The current approach of calling the hook inside Wide/Narrow was chosen to avoid prop drilling, per the earlier review guidance from @adhorodyski to use named hooks rather than passing computed values as props. Both Wide and Narrow already receive searchResults as a prop, so the hook has everything it needs without additional plumbing.
|
@szymonzalarski98 we have a couple of failing tests |
|
@luacmartins Hey, I have a regression in Reports -> Card statements - skeleton laoding is blinking, empty state is visible for 0.5 sec and then there is no content, I need to fix it |
…e-skeleton-to-top-level-search-page
…to-top-level-search-page' of github.com:callstack-internal/Expensify-App into callstack-internal/szymonzalarski/search/move-skeleton-to-top-level-search-page
…szymonzalarski/search/move-skeleton-to-top-level-search-page
|
@FitseTLT how's review going? |
|
@szymonzalarski98 we have conflicts |
|
@szymonzalarski98 is it ready for my review? U haven't requested my review. And I also see some of my comments have no response or marking as resolved. Have u addressed all comments? |
…szymonzalarski/search/move-skeleton-to-top-level-search-page # Conflicts: # Mobile-Expensify # src/pages/Search/SearchPage.tsx # src/pages/Search/SearchPageNarrow.tsx # src/pages/Search/SearchPageWide.tsx
…e-skeleton-to-top-level-search-page
|
@FitseTLT please prioritize this review when you're online |
@szymonzalarski98 is it ready for review? |
Yes, it's ready, thank you |
|
Waiting for your responses on some comments @luacmartins @szymonzalarski98 |
|
Replied |
|
Hey, PR is updated and I've replied to comments |
Reviewer Checklist
Screenshots/VideosAndroid: HybridAppAndroid: mWeb ChromeiOS: HybridAppiOS: mWeb SafariMacOS: Chrome / Safari |
|
Left with comments @szymonzalarski98 |
Speeds up skeleton rendering on the Reports page by lifting the search context setup (setCurrentSearchHashAndKey, setCurrentSearchQueryJSON) and openSearch() from the Search component to a new useSearchPageSetup hook called at the SearchPage level.
Previously, when navigating to Reports, the Onyx snapshot subscription hash was set inside Search via useFocusEffect, meaning the context had to wait for Search to mount and its effects to run before the correct snapshot was available. This added an extra render cycle before shouldShowLoadingState could be computed correctly and the skeleton could appear.
Now, useSearchPageSetup sets the context hash at the SearchPage level before Search mounts, so when Search initializes its hooks, the Onyx subscription already points to the correct snapshot. This eliminates the extra render cycle and allows the skeleton to appear immediately on first render.
Key changes:
New useSearchPageSetup hook (src/hooks/useSearchPageSetup.ts): Extracted from Search — handles setCurrentSearchHashAndKey, setCurrentSearchQueryJSON, clearSelectedTransactions (via useFocusEffect + mount effect), openSearch() on mount, and openSearch() on reconnect.
SearchPage.tsx: Added useSearchPageSetup(queryJSON) call.
Search/index.tsx: Removed clearTransactionsAndSetHashAndKey + its useFocusEffect + mount effect, removed openSearch() mount and reconnect effects (all moved to the hook). Skeleton rendering and all other logic unchanged.
SearchPageWide.tsx / SearchPageNarrow.tsx: No changes — Search still renders unconditionally and handles skeleton internally.
Fixed Issues
$ #83342
PROPOSAL:
Proposal: Lift Search Skeleton and API Initialization to SearchPage
Background: The Reports page is organized as a hierarchy starting with
SearchPage, which acts as a router to eitherSearchPageWideorSearchPageNarrow. These sub-pages mount theSearchcomponent, which serves as the primary data-fetching and display layer. TheSearchcomponent manages approximately 14 Onyx subscriptions, includingCOLLECTION.TRANSACTIONandCOLLECTION.POLICY, to populate the reports list.Problem: When a user navigates to the Reports page with no cached Onyx data, if the
Searchcomponent must fully initialize its ~14 Onyx subscriptions and associated hooks before the skeleton can render, then there is an observable 374ms window where the screen is blank, causing users to perceive the application as frozen.Solution: We will move the
SearchRowSkeletonand the initialopenSearch()API call to theSearchPagelevel. This decoupling ensures that the user receives instant visual feedback (the skeleton) the moment the route is matched, rather than waiting for the heavy data-subscription layer to mount and initialize.Measurable Impact:
Tests
Offline tests
QA Steps
Same as Tests above — the change is purely in rendering timing, no new user-facing features. Focus on verifying there are no regressions in:
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.ScrollViewcomponent to make it scrollable when more elements are added to the page.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari