Skip to content

Search Router open: restore option cache hits, skip empty-query navigation work, profile on native - #103982

Draft
sumo-slonik wants to merge 4 commits into
Expensify:mainfrom
software-mansion-labs:perf/android-search-router-open
Draft

sumo-slonik wants to merge 4 commits into
Expensify:mainfrom
software-mansion-labs:perf/android-search-router-open

Conversation

@sumo-slonik

@sumo-slonik sumo-slonik commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

Work toward the ManualOpenSearchRouter target (#79353), aimed at the Android tail. This PR does not reach the 400 ms target on its own, and its production effect is not proven yet. It removes work that is wasted on every open, and adds the profiling needed to find what fills the rest of the tail.

Who is in the Android tail (Sentry, production, week of 2026-10-05): small accounts on slow devices. The median Android opener has 2 workspaces, ~15 reports and ~7 contacts. Of the 46 Android opens above 900 ms, only 2 came from an account with more than 9 workspaces; the rest are low/medium devices with 1-4 workspaces and under ~35 reports. On low devices with at most 50 reports, the time to the skeleton (ManualOpenSearchRouterPageVisible) is p50 357 ms of a p50 720 ms open, so both halves matter. The tail is mostly a fixed cost per open, not data size.

1. The option list cache missed on every open since 9.4.89 (#99270). useFilteredOptions feeds pendingDeleteMemberAccountIDsByReportID into the reference-keyed cache in createFilteredOptionList (#95378). Its selector built a new {} for every subscriber (useOnyx memoizes selectors per hook instance), and the Search Router mounts fresh on every open, so this input changed reference each time and the list was rebuilt on every open, which also made SearchRouterWarmup useless. The selector now returns one shared frozen object when nothing is pending. The miss follows from the code for every account, but the saved work grows with the number of reports, so for the median Android account it is small; it matters more for larger accounts.

2. Navigation suggestions are no longer built for the empty query every open starts with. useNavigationSuggestions built all its sources and buildNavigationSuggestions then returned []. Now the hook returns early with the same condition, and useSearchTypeMenuSections gets a flag to skip createTypeMenuSections, which parses each canned query. Two parts:

  • the settings menu of every workspace: grows with workspace count, so it reaches ~5% of Android opens and almost none of the tail;
  • the Spend sections: a fixed cost on every open (20-35 ms per open in the emulator dev-build profiles), which is the part that reaches small accounts. Its size on real low-end devices is not measured.

3. Hermes profiling of the open on native, to find what fills the small-account tail. No 9.4/9.5 build has produced profiles: hermesProfilingIntegration needs profilesSampleRate, and _experiments.profilingOptions never reaches the native SDK because of autoInitializeNativeSdk: false. A global rate would cut every profile at the next root span (every Manual* span is a root since #101877), run the synchronous stop inside the next measured span, and flood the quota. searchRouterProfilingSampler keeps the rate at 0 except for the navigation span dispatched during a Search Router open (Android 20%, iOS 5%, JS profiler only) and stamps profiled on the open span, so metric comparisons can exclude profiled opens with !tags[profiled,boolean]:true. A test pins profilesSampleRate out of Sentry.init, because the SDK would then add its own profiler ahead of the sampler. Not run on a device yet.

Emulator measurement. Limits: dev build (absolute numbers are inflated), one emulator, and an atypical account with ~255 workspaces and ~1565 reports, so this shows the mechanisms work and does not predict the production gain. Warm opens, base and fix alternated in blocks of 4; the first open after each reload (a cache rebuild) is excluded from the fix rows, and adding those rows back changes the medians to 1362 and 2182 ms.

ManualOpenSearchRouter median ManualOpenSearchRouterPageVisible median
main 2807 ms (n=14) 1333 ms
fix 1 only (one block, not alternated) 2139 ms (n=6) 1313 ms
fixes 1 + 2 (before the Spend-section skip) 1350 ms (n=13) 490 ms

How the effect will be checked after release: old and new native versions run side by side, so compare Android p50/p95 by release and device.class, restricted to tags[reports_count_raw,number]:<=50 for the small-account tail, with profiled opens excluded.

Fixed Issues

$ #79353
PROPOSAL:

Tests

  1. Sign in on Android native with an account that has several workspaces.
  2. Tap the Search button on Inbox and verify the Search Router opens with recent searches and recent chats.
  3. Type go to and verify navigation suggestions (top-level destinations, Spend, workspace pages, Account) still appear.
  4. Type the name of one of your workspaces and verify its workspace pages are suggested.
  5. Clear the query and verify the list returns to recent chats.
  6. Close and reopen the Search Router several times and verify the list content is the same each time.
  • Verify that no errors appear in the JS console

Offline tests

  1. Go offline.
  2. Open the Search Router and verify recent chats are shown.
  3. Type go to and verify navigation suggestions appear.

QA Steps

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 verified any copy / text that was added to the app 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 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 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 the PR adds or modifies the UI:
    • I asked an AI agent to review the changes for accessibility issues and addressed its findings.
    • I tested with a screen reader (VoiceOver on macOS) and verified all new/changed elements are reachable with a logical focus order.
    • I verified all new/changed elements have meaningful accessible names and roles.
    • I verified state changes are announced (e.g. checked/unchecked, expanded/collapsed, selected).
  • 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

@sumo-slonik sumo-slonik changed the title Speed up Search Router open: restore option cache hits, skip empty-query navigation suggestions, profile on native Search Router open: restore option cache hits, skip empty-query navigation work, profile on native Oct 14, 2026

This branch has not been deployed

No deployments
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.

1 participant