Fix performance of search router in p95 tail - #98282
Conversation
Codecov Report❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.
|
Replace the 15s timer in SearchRouterWarmup with the state it was approximating: wait for OpenApp to be applied, then for an idle window on the JS thread via Scheduler.scheduleWhenIdle. Build the option list only once its Onyx inputs hold still across a full idle window. createFilteredOptionList keys its cache on the identity of those inputs, so a list built while post-launch writes are still landing is discarded before the user can open the router. A connectWithoutView counter tracks that churn without rendering or rebuilding anything, and a cap on the number of idle windows keeps a chatty account from postponing the build forever. Module evaluation is unaffected by Onyx writes, so it still happens in the first idle window. Also: - evict the attachment-parse cache LRU instead of clearing every entry at the cap - share the useFilteredOptions config between SearchAutocompleteList and SearchRouterOptionsWarmer so the cache key cannot drift apart - share the SearchRouterPage/RightModalNavigator require paths between AuthScreens and the warmup - pick the empty-search predicate once per getValidOptions call instead of testing for it per report
Measured on the SearchRouter open path with the changes stacked one at a time (Android emulator, high-traffic account, 3 cold opens per state): adding this cache moved the median from 1840ms to 1936ms, i.e. no gain, with the run ranges fully overlapping. The reason is visible in the data: the cache lookup sits behind the `data-expensify-source` fast path, and on the test account only 4 of 635 reports carry that attribute, so the parser barely runs on this path at all. Keeping the cache would mean retaining message HTML strings for a saving that does not exist here. If it is worth having, it belongs in a change aimed at the report view, justified by measurements of that path.
Same stacked measurement as the previous commit: adding this step moved the median from 1957ms (main) to 1985ms, with the run ranges fully overlapping main's, so it buys nothing on the SearchRouter open path. Building and deburring the search text for an empty query is genuinely redundant, but at this size it does not show up next to the option-list build that dominates the open, so it is not worth the review surface in a perf PR.
staszekscp
left a comment
There was a problem hiding this comment.
So far looking great!
The blank-query short-circuit skipped the parser's first-use module evaluation, which measured at ~76ms on an Android dev build. That cost is Hermes compiling the generated parser at runtime, which a release build does not pay - its bundle is precompiled to bytecode. The parsing itself is 0.07ms per call, so the guard is not worth the extra branch.
|
@aimane-chnaif 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] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6864931e12
ℹ️ 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".
| const openSearchRouter = (query?: string, isFromSearchPageSearchButton?: boolean) => { | ||
| isSearchRouterOpenOrOpening = true; |
There was a problem hiding this comment.
Reset the open flag when native navigation dismisses the route
When the native Search Router is dismissed through Android hardware Back or an iOS navigation gesture, React Navigation removes SCREENS.SEARCH_ROUTER.ROOT without calling closeSearchRouter, so this module-level flag remains true until sign-out. The native useSyncModalWithHistory is a no-op, and the route's beforeRemove listener in AuthScreens.tsx only updates modal state and cancels requests. Consequently, if the user opens Search before the initial idle warmup and dismisses it through navigation—or if a later reconnect needs to rewarm invalidated data—both warmers keep treating the closed router as open and skip the optimization for subsequent opens.
Useful? React with 👍 / 👎.
| import {getIsSearchRouterOpenOrOpening} from './SearchRouterContext'; | ||
|
|
||
| type OptionsWarmerProps = { | ||
| onDone: () => void; |
There was a problem hiding this comment.
❌ CONSISTENCY-13 (docs)
The newly added OptionsWarmerProps type declares the onDone prop with no /** ... */ block comment. Per STYLE.md, every component prop should be documented with a JSDoc block comment so its purpose is clear at the definition site. Note the sibling SearchRouterOptionsWarmerProps type already documents the same prop.
Add a block comment above the prop:
type OptionsWarmerProps = {
/** Called once the option list is cached (or the warm is no longer needed), so the parent can unmount this component. */
onDone: () => void;
};Reviewed at: 6864931 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
| onDone: () => void; | ||
| }; | ||
|
|
||
| // Stop waiting for quiet after this many idle windows; a chatty account never fully stops writing. |
There was a problem hiding this comment.
❌ CONSISTENCY-16 (docs)
This comment uses a semicolon to join two independent clauses. Comments should read as plain sentences, so split it into two separate sentences instead of using a semicolon.
// Stop waiting for quiet after this many idle windows. A chatty account never fully stops writing.
const MAX_QUIET_WAIT_ATTEMPTS = 5;Reviewed at: 6864931 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.
The native route mounts on a deep link or a restored last-visited path without openSearchRouter, and hardware Back and the iOS gesture pop it without closeSearchRouter, so set and reset the module-level flag from the page itself.
ReviewOverall this is a solid, well-reasoned change — the design (idle warm-up gated on Onyx inputs holding still, config extracted so the warmer and the live screen key the cache identically, unmount-when-done) is sound and unusually well-commented. The earlier bot findings (native back/gesture flag leak, comment style, prop docs) all look addressed. A few things worth a look before merge: 1. No tests for the new warm-up logic (main gap)The 8-file diff adds no test, and Codecov flags a coverage drop — yet the author checklist claims unit tests were added. The pieces most likely to regress silently are pure logic and easy to cover:
2. Churn heuristic covers a subset of the cache inputs (non-blocking)The warmer waits for 3. Re-arm on every
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8cd9dcca95
ℹ️ 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".
| if (churnVersionRef.current !== lastSeenChurn && attempts < MAX_QUIET_WAIT_ATTEMPTS) { | ||
| lastSeenChurn = churnVersionRef.current; | ||
| attempts += 1; | ||
| waitForQuiet(); |
There was a problem hiding this comment.
Yield between quiet-period checks
When React Scheduler still has budget, an idle-priority callback scheduled from a currently running idle callback can execute in the same scheduler work loop; Scheduler.scheduleWhenIdle only adds a competing 300 ms fallback and does not guarantee a new event-loop window. Consequently, this recursive call can immediately observe the same churnVersionRef, enable the build, and unmount the warmer before queued post-OpenApp Onyx deliveries run. On accounts whose updates arrive just after that scheduler flush, the next delivery invalidates the transient cache entry, so the first Search open still performs the full build while startup also paid for the discarded warmup. Introduce an actual yield between checks or restart a quiet-period timer when an input changes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed: a recursive scheduleWhenIdle re-runs inside the same scheduler work loop (8 recursions in one macrotask, 0.085 ms), so the quiet-wait barely waits. I built the real debounce (300 ms per watched input, 2 s cap) and A/B'd it against this branch on Android — interleaved variants, 6 paired rounds, cold launch, tap after a 25 s settle: the debounce variant lands +8.6% slower than this branch (median 1028 ms vs 943 ms, slower in 5/6 rounds), so making the wait real buys nothing and I'd rather not grow the warm-up code for it here.
Cache hit rate is the remaining gap and I'll take it in a follow-up: the warm entry is still invalidated by Onyx writes right after launch, exactly as you point out, so this is better than before but not yet ideal.
|
|
aimane-chnaif
left a comment
There was a problem hiding this comment.
Please pull main.
And check bot comments. It's fine to skip edge cases.
Code-quality reviewQuality is high overall — every non-obvious decision has a why comment, magic values are named, and the earlier bot nits (prop JSDoc, comment style, native flag reset) are all fixed. A few refinements, none blocking: 1. Duplicated prop type across the two warmer files — CONSISTENCY-3
2. The churn / quiet-wait loop wants to be its own hook — CLEAN-REACT-PATTERNS-4In 3.
|
Regression huntBottom line: no blocking regression found. I exercised the full search flow at runtime on web and statically traced the behavioral changes — everything that could break still works. Two things are worth a second look (neither reproduced), and one PR-checklist claim (no console errors) I couldn't verify because the web driver can't read the JS console. Runtime results (web)All six flow steps passed — open shows recent chats (non-empty, no duplicates), typing returns matching results, Clear restores the exact prior list, clicking a recent report opens the right one, and reopening after a ~30s idle window (warm-up runs) shows the same list with no duplication or loss. Opening before the first idle also worked.
Static analysis — regression vectors checked and cleared
Worth a second look (not reproduced)
Verification gapThe web driver has no JS-console access, so the PR's "no console errors" test step is unverified — no visible error banners or broken UI appeared, but I can't confirm a clean console. Worth a manual console check during QA. |
# Conflicts: # src/components/Search/SearchAutocompleteList.tsx
|
Hi @aimane-chnaif, I think this PR is ready for a second round of review. |




Explanation of Change
Cuts the
ManualOpenSearchRouterp95 tail (Sentry: "Opening Search bar"). Android is 12% of samples but 53% of everything above the global p95, withcold_start=falsein 99.6% — the tail is the session's first open landing in post-launch JS-thread contention.New
SearchRouterWarmup/SearchRouterOptionsWarmer, mounted fromAuthScreens, move that first-open work off the critical path. After OpenApp is applied, the first idle window (Scheduler.scheduleWhenIdle) evaluates theSearchRouterPage/RightModalNavigatormodule graphs and builds the empty-query option list intocreateFilteredOptionList's cache. That cache is keyed on the identity of its Onyx inputs, so the list is only built once they hold still across an idle window — otherwise post-launch writes would immediately invalidate it. The warmer unmounts when done, and skips entirely if the router is already open.The config those options are built from now lives in
searchRouterOptionsConfig.ts, so the warmer andSearchAutocompleteListcan't drift apart on the values that cache is keyed on.An earlier revision also skipped the autocomplete parser for blank queries. Dropped — parsing is 0.07ms per call, and the rest of the delta was a dev-only Hermes compile cost (see the discussion on
useAutocompleteSuggestions.ts).Local measurements (dev builds — absolute ms are not production values, only the relative change matters)
iOS
Android
Fixed Issues
$ #79353
PROPOSAL:
Tests
Offline tests
Unnecessary
QA Steps
Same as tests.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, 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.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