Skip to content

Refactor search functionality to improve loading state handling#83917

Open
szymonzalarski98 wants to merge 18 commits intoExpensify:mainfrom
callstack-internal:callstack-internal/szymonzalarski/search/move-skeleton-to-top-level-search-page
Open

Refactor search functionality to improve loading state handling#83917
szymonzalarski98 wants to merge 18 commits intoExpensify:mainfrom
callstack-internal:callstack-internal/szymonzalarski/search/move-skeleton-to-top-level-search-page

Conversation

@szymonzalarski98
Copy link
Contributor

@szymonzalarski98 szymonzalarski98 commented Mar 2, 2026

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 either SearchPageWide or SearchPageNarrow. These sub-pages mount the Search component, which serves as the primary data-fetching and display layer. The Search component manages approximately 14 Onyx subscriptions, including COLLECTION.TRANSACTION and COLLECTION.POLICY, to populate the reports list.

Problem: When a user navigates to the Reports page with no cached Onyx data, if the Search component 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 SearchRowSkeleton and the initial openSearch() API call to the SearchPage level. 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:

  • Reduced Initial Latency: The visual feedback loop (time to skeleton) drops from 374ms to ~204ms (average).

Tests

  • Cold start (no cache): Clear IndexedDB → hard refresh → log in → navigate to Reports → verify skeleton appears immediately, then content loads
  • Cached data: With data already loaded, navigate away (Chat) → navigate back to Reports → verify content appears directly without skeleton flash
  • Filter change: On Reports page, change search type (e.g., Expenses → Invoices) → verify skeleton shows during transition, then new results load
  • Sorting: Click a column header to sort → verify old data stays visible (no skeleton flash), sorted data replaces it
  • Pagination: Scroll to bottom of results → verify "loading more" skeleton appears at bottom (not full-page skeleton)
  • Narrow layout: Resize browser to narrow width → repeat steps 1-3 → verify same behavior on SearchPageNarrow

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:

  • Report selection (checkboxes, bulk actions)
  • Search header/filters bar visibility
  • Footer totals display
  • Navigation between search views

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
      • If any non-english text was added/modified, I used JaimeGPT to get English > Spanish translation. I then posted it in #expensify-open-source and it was approved by an internal Expensify engineer. Link to Slack message:
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methxods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • I verified that if a function's arguments changed that all usages have also been updated correctly
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari

@@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We changed this span. We now track a single span. We should also remove it from the search page, since we

const onLayoutSkeleton = useCallback(() => {
hasHadFirstLayout.current = true;
endSpanWithAttributes(CONST.TELEMETRY.SPAN_NAVIGATE_TO_REPORTS, {[CONST.TELEMETRY.ATTRIBUTE_IS_WARM]: false});
}, []);

useEffect(() => {
openSearch({includePartiallySetupBankAccounts: true});
}, []);

Copy link
Contributor

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

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}
/>
</Animated.View>
);
}


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(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should go into a named hook(s), do not inline effects

confirmPayment={stableOnBulkPaySelected}
latestBankItems={latestBankItems}
shouldShowFooter={shouldShowFooter}
shouldShowLoadingState={shouldShowLoadingState}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

isMobileSelectionModeEnabled={isMobileSelectionModeEnabled}
shouldShowLoadingState={shouldShowLoadingState}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

PDFValidationComponent,
ErrorModal,
shouldShowFooter,
shouldShowLoadingState,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commented across the PR on this prop drilling violation of the review rules (see .claude/skills/coding-standards)

@szymonzalarski98 szymonzalarski98 force-pushed the callstack-internal/szymonzalarski/search/move-skeleton-to-top-level-search-page branch from f076b0e to 5af0782 Compare March 5, 2026 15:26
…ymonzalarski/search/move-skeleton-to-top-level-search-page

# Conflicts:
#	src/components/Search/index.tsx
#	src/pages/Search/SearchPage.tsx
@szymonzalarski98 szymonzalarski98 marked this pull request as ready for review March 5, 2026 16:58
@szymonzalarski98 szymonzalarski98 requested review from a team as code owners March 5, 2026 16:58
@melvin-bot melvin-bot bot requested review from FitseTLT and removed request for a team March 5, 2026 16:58
@melvin-bot
Copy link

melvin-bot bot commented Mar 5, 2026

@FitseTLT Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@melvin-bot melvin-bot bot requested review from JmillsExpensify and removed request for a team March 5, 2026 16:58
@szymonzalarski98
Copy link
Contributor Author

@codex review

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +21 to +22
const isDataLoaded = isSearchDataLoaded(currentSearchResults, queryJSON);
const isLoadingWithNoData = !!currentSearchResults?.search?.isLoading && Array.isArray(currentSearchResults?.data) && currentSearchResults.data.length === 0;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

import useNetwork from './useNetwork';
import usePrevious from './usePrevious';

let didOpenSearch = false;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@szymonzalarski98
Copy link
Contributor Author

@luacmartins @adhorodyski Hey, I've updated the PR

@FitseTLT
Copy link
Contributor

FitseTLT commented Mar 5, 2026

Does this need c+ review @luacmartins ?

import usePrevious from './usePrevious';

let didOpenSearch = false;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ 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.

@luacmartins
Copy link
Contributor

@FitseTLT yes! Please prioritize this review when you're online

Copy link
Contributor

@JmillsExpensify JmillsExpensify left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No product review required.

@FitseTLT
Copy link
Contributor

FitseTLT commented Mar 5, 2026

@FitseTLT yes! Please prioritize this review when you're online

on it

@luacmartins
Copy link
Contributor

Messaged the prettier diff in Slack

@luacmartins
Copy link
Contributor

@FitseTLT please prioritize this review when you're online

@FitseTLT
Copy link
Contributor

FitseTLT commented Mar 6, 2026

@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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we take shouldShowLoadingState here in Search from useSearchLoading hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Comment on lines +27 to +30
const hasNoData = currentSearchResults?.data === undefined;

return (!isDataLoaded && hasNoData) || isLoadingWithNoData || isCardFeedsLoading;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. BTW adding && hasNoData to !isDataLoaded will make !isDataLoaded logic unnecessary because the inequality between searchResult.search.type and queryJson.type (etc) inside isSearchDataLoaded will now have no effect in the logic.
  2. Replacing searchResults with currentSearchResults to 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

Copy link
Contributor Author

@szymonzalarski98 szymonzalarski98 Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both addressed:

  1. 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.
  2. 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding (1) I think you need to recheck. Because what I commented about was (!isDataLoaded && hasNoData) but u instead removed || isLoadingWithNoData || isCardFeedsLoading; 😄

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waiting for your response here @szymonzalarski98

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

// There's a race condition in Onyx which makes it return data from the previous Search, so in addition to checking that the data is loaded
// we also need to check that the searchResults matches the type and status of the current search
const isDataLoaded = shouldUseLiveData || isSearchDataLoaded(searchResults, queryJSON);
const hasErrors = Object.keys(searchResults?.errors ?? {}).length > 0 && !isOffline;
// For to-do searches, we never show loading state since the data is always available locally from Onyx
const shouldShowLoadingState =
!shouldUseLiveData &&
!isOffline &&
(!isDataLoaded ||
(!!searchResults?.search.isLoading && Array.isArray(searchResults?.data) && searchResults?.data.length === 0) ||
(hasErrors && searchRequestResponseStatusCode === null) ||
isCardFeedsLoading);

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

if (isSearchDataLoaded(currentSearchResults, queryJSON) || currentSearchResults?.search?.isLoading) {
return;
}
search({queryJSON, searchKey, offset: 0, shouldCalculateTotals: false, isLoading: false});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is this search coming from? I thought we were not adding a new logic here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is shouldCalculateTotals passed as false? Doesn't it depend on the searchHash like here

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know let @luacmartins defer on this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will using shouldShowLoadingSkeleton in SearchPage and passing it as a prop for both improve the timing even more?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@luacmartins
Copy link
Contributor

@szymonzalarski98 we have a couple of failing tests

@szymonzalarski98
Copy link
Contributor Author

@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

szymonzalarski98 and others added 4 commits March 10, 2026 09:20
…to-top-level-search-page' of github.com:callstack-internal/Expensify-App into callstack-internal/szymonzalarski/search/move-skeleton-to-top-level-search-page
@luacmartins
Copy link
Contributor

@FitseTLT how's review going?

@luacmartins luacmartins requested a review from FitseTLT March 11, 2026 19:58
@luacmartins
Copy link
Contributor

@szymonzalarski98 we have conflicts

@FitseTLT
Copy link
Contributor

@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
@luacmartins
Copy link
Contributor

@FitseTLT please prioritize this review when you're online

@FitseTLT
Copy link
Contributor

@FitseTLT please prioritize this review when you're online

@szymonzalarski98 is it ready for review?

@szymonzalarski98
Copy link
Contributor Author

@FitseTLT please prioritize this review when you're online

@szymonzalarski98 is it ready for review?

Yes, it's ready, thank you

@FitseTLT
Copy link
Contributor

Waiting for your responses on some comments @luacmartins @szymonzalarski98

@luacmartins
Copy link
Contributor

Replied

@szymonzalarski98
Copy link
Contributor Author

Hey, PR is updated and I've replied to comments

@FitseTLT
Copy link
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified there are no new alerts related to the canBeMissing param for useOnyx
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text shown in the product is localized by adding it to src/languages/* files and using the translation method
    • I verified all numbers, amounts, dates and phone numbers shown in the product are using the localization methods
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
    • I verified proper file naming conventions were followed for any new files or renamed files. All non-platform specific files are named after what they export and are not named "index.js". All platform-specific files are named for the platform the code supports as outlined in the README.
    • I verified the JSDocs style guidelines (in STYLE.md) were followed
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • I verified all code is DRY (the PR doesn't include any logic written more than once, with the exception of tests)
  • I verified any variables that can be defined as constants (ie. in CONST.ts or at the top of the file that uses the constant) are defined as such
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately and each prop has a /** comment above it */
    • The file is named correctly
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • [ x If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • If a new page is added, I verified it's using the ScrollView component to make it scrollable when more elements are added to the page.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp
Android: mWeb Chrome
iOS: HybridApp
iOS: mWeb Safari
MacOS: Chrome / Safari

@FitseTLT
Copy link
Contributor

FitseTLT commented Mar 13, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants