Skip to content

fix: recover Search snapshots stuck on a stale error instead of looping on Try again - #98412

Open
BartekObudzinski wants to merge 7 commits into
Expensify:mainfrom
callstack-internal:fix/search-error-snapshot-blocks-recovery
Open

fix: recover Search snapshots stuck on a stale error instead of looping on Try again#98412
BartekObudzinski wants to merge 7 commits into
Expensify:mainfrom
callstack-internal:fix/search-error-snapshot-blocks-recovery

Conversation

@BartekObudzinski

@BartekObudzinski BartekObudzinski commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

The Search snapshot for a query stores an errors object when a request fails. Normally the next request's optimistic data clears it. Two places both treat a present errors object as a sign the snapshot is resolved: isSearchDataLoaded in SearchUIUtils.ts, which gates the effect in useSearchPageSetup.ts that fires the next request, and the early return in Search/index.tsx while hasErrors is true. Each check is reasonable on its own, but together they trap a query. One failed request writes errors to disk, and after that nothing fires a new request to clear it. The Search page renders its error view on every mount, with nothing in flight, and only the Try again button breaks the loop. Because the snapshot lives on disk, this survives navigation and app restarts.

The fix lives entirely in useSearchPageSetup.ts. A snapshot whose only problem is errors no longer blocks the request on mount. A ref remembers which hashes already got a retry this mount, so a persistent failure still settles on the error view instead of looping request, failure, request forever. Queries the server already rejected as malformed (responseJsonCode of INVALID_SEARCH_QUERY) are skipped, since sending them again cannot help.

SearchUIUtils.ts and Search/index.tsx are not changed. The page level request already clears errors before the component reaches its error branch, so neither file needs to change for the fix to work.

Fixed Issues

$ #98312
PROPOSAL:

Tests

  1. Open the Spend page and wait for the expenses to load.
  2. Make a single Search request fail, so the snapshot is left with an error stored on it. On a dev build the quickest way is to reject command === 'Search' at the top of xhr() in src/libs/HttpUtils.ts.
  3. Verify the full page error appears, with the "Oops... Something went wrong" heading and a Try again button.
  4. Undo the change from step 2, so the app is back on healthy code with a working connection.
  5. Relaunch the app and open the Spend page again.
  6. Verify the expenses load on their own after a brief skeleton. On main the error page stays up and only tapping Try again brings the data back.
  7. Repeat steps 2 and 3, then relaunch once more while the request is still failing.
  8. Verify the page settles back on the error view rather than retrying over and over.

Please do not test this by turning the network off. hasErrors is gated on !isOffline, so going offline hides the error view and the bug never shows up.

  • Verify that no errors appear in the JS console

Offline tests

N/A

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."

Same as tests

  • Verify that no errors appear in the JS console

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 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 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)
  • 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)
  • 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.
  • 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
after-fix.mp4
iOS: mWeb Safari
MacOS: Chrome / Safari

@BartekObudzinski
BartekObudzinski marked this pull request as ready for review August 12, 2026 12:19
@BartekObudzinski
BartekObudzinski requested review from a team as code owners August 12, 2026 12:19
@melvin-bot
melvin-bot Bot requested review from DylanDylann and heyjennahay and removed request for a team August 12, 2026 12:19
@melvin-bot

melvin-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

@DylanDylann 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]

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

@codex review

@melvin-bot
melvin-bot Bot removed the request for review from a team August 12, 2026 12:19
Comment thread src/hooks/useSearchPageSetup.ts Outdated
// The Search page then renders its error view with nothing in flight, and only the Try again
// button can break out of it. Allow one recovery attempt per hash instead, so a transient failure
// heals by itself while a persistent one still settles on the error view rather than looping
// request -> failure -> request.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-16 (docs)

Comments should read as plain, natural sentences. This newly added comment uses -> to express a relationship (looping request -> failure -> request) instead of writing the relationship out in words, which the plain-comment-style rule specifically calls out.

Rewrite the relationship in words, for example:

// heals by itself while a persistent one still settles on the error view rather than looping
// from request to failure and back to request.

Reviewed at: 5c39077 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Breezy!

Reviewed commit: 5c3907710f

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mountiny

Copy link
Copy Markdown
Contributor

@MelvinBot review

@MelvinBot

Copy link
Copy Markdown
Contributor

Review

Verdict: The fix is correct, well-scoped, and thoroughly tested. It targets the real trap — isSearchDataLoaded counts a present errors object as "resolved", so an errored snapshot looks loaded and the effect's early return skips the very request that would clear it. Gating the early return on a one-shot per-hash recovery attempt is the minimal, right lever. All CI test jobs and Codex pass. I'd approve after the two small clarifications below (neither blocking).

I confirmed the two load-bearing assumptions the fix rests on:

The loop-prevention reasoning is sound end to end: recovery fires → optimistic clears errors (so hasRecoverableErrors flips false and the effect re-runs harmlessly, deduped in flight) → on persistent failure errors is written back but the hash is now in the Set, so the early return re-engages and the page settles on the error view. No request → failure → request loop.

Non-blocking observations

  1. Non-INVALID_SEARCH_QUERY failure codes are all treated as retryable. hasRecoverableErrors only excludes INVALID_SEARCH_QUERY (401). A snapshot with e.g. NOT_AUTHENTICATED (407) or a stale/undefined responseJsonCode gets one retry. That's the safe default (a single extra request, self-limiting via the Set), so it's fine — just confirming it's intentional that only the malformed-query code is treated as non-recoverable rather than a broader allowlist of "don't retry" codes.

  2. errors != null treats any non-null errors object as recoverable, including a hypothetical empty {}. In practice failures always write a keyed error object so this can't misfire today, but if a future path ever wrote errors: {} it would trigger a retry the error view itself wouldn't render for. A comment or an "is non-empty" guard would harden it; not worth changing now.

Tests

Coverage is excellent — the new useSearchPageSetupTest.tsx exercises: re-request on an errored snapshot, no second attempt after a failed retry, attempt preserved when search() declines, malformed-query skip, and per-hash tracking across query switches. The search()-return-value detail is correctly modeled in the mock. The reload-case UI tests (persisted responseJsonCode distinguishing invalid vs. retryable) round it out nicely. Good use of comments explaining why each assertion matters.

Nice, careful work — the inline comments make the non-obvious Onyx/effect interactions easy to follow.

@BartekObudzinski

Copy link
Copy Markdown
Contributor Author

Both observations are handled.

The errors check now uses the same non empty rule as the error view, so the two gates cannot drift apart.

On the response codes, yes that is intentional. 401 is the only code where the server has judged the query itself, so re sending it can never succeed. Every other code is a failure of that one attempt, and a single retry is exactly what recovers it. The Set caps it at one extra request either way, so a broader deny list would only cost us recoveries without preventing anything.

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.

3 participants