Skip to content

perf(Search): row-tap cascade kill via row-level live recompute + compiler-stable refs#91438

Merged
mountiny merged 24 commits into
Expensify:mainfrom
callstack-internal:decompose/search-screen
Jun 4, 2026
Merged

perf(Search): row-tap cascade kill via row-level live recompute + compiler-stable refs#91438
mountiny merged 24 commits into
Expensify:mainfrom
callstack-internal:decompose/search-screen

Conversation

@adhorodyski

@adhorodyski adhorodyski commented May 22, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

The Search screen ran a parallel double-merge of live Onyx data: getSections pulled the snapshot plus ~18 live inputs (policies, cardFeeds, allTransactionViolations, allReportMetadata, reportActions, reportAttributesDerivedValue, …) while each row was already self-hydrating the same data via its own subscriptions. Row taps wrote optimistic primers that fed back into the screen's live deps, invalidating the 28-dep useMemo, re-creating the 24-dep renderItem, and reconciling every visible row in the tap frame — visible jank in SPAN_OPEN_REPORT (16–45ms).

This PR removes the parallel merge and lets each row own its live computation:

  • A1/A2 — Drop reportActions, exportReportActions, allReportMetadata, reportAttributesDerivedValue from <Search> and getSections. getSections becomes snapshot-only for these inputs.
  • Capability booleans — Replace the array-valued allActions field on row items with four primitive booleans (canPay/canApprove/canSubmit/canChangeApprover) flowing through selection state and bulk-action consumers. Inlined as primitives at row level so the React Compiler tracks each as a value-dep.
  • useLiveRowCapabilities — Row-level live recompute lives in a single hook consumed by ExpenseReportListItem, TransactionGroupListItem, TransactionListItem. Wave C PRs edit the hook, not the three call sites.
  • useRowSelection — Per-row selection read replaces applySelectionToItem. The old screen-level selection dep on renderItem is gone; applySelectionToItem is deleted.

Measurements (React DevTools Profiler, 5 trials of warm row-tap, averaged):

Metric Before After Delta Delta %
Total commits 76.8 60.4 -16.4 -21.4%
Total commit duration (ms) 631.4 502.1 -129.3 -20.5%
Components rendered 6880 5459 -1421 -20.7%

PRD: docs/superpowers/plans/2026-05-20-search-decomposition-prd.md

Fixed Issues

$ #92080
PROPOSAL:

Tests

  1. Open Search with a populated expense report list; tap a row → RHP opens without stall, SPAN_OPEN_REPORT P90 below baseline
  2. Multi-select 5+ rows → bulk Pay/Approve/Submit/Change-Approver enablement reflects live state
  3. Open hold-menu on a payable expense → nonHeldAmount math reflects PAY availability
  4. Switch variants (Chat, Task, Expense flat, Expense grouped, Expense report) → each renders correctly
  5. Pusher update for a visible report → row updates without flickering siblings
  • Verify that no errors appear in the JS console

Offline tests

  1. Disconnect; open Search → snapshot renders rows correctly
  2. Reconnect → rows pick up live data without errors

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 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
    • 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 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

@adhorodyski adhorodyski force-pushed the decompose/search-screen branch from 8c4aca1 to 0c40c99 Compare May 25, 2026 11:19
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR is possibly changing native code and/or updating libraries, it may cause problems with HybridApp. Please check if any patch updates are required in the HybridApp repo and run an AdHoc build to verify that HybridApp will not break. Ask Contributor Plus for help if you are not sure how to handle this. ⚠️

@codecov

codecov Bot commented May 27, 2026

Copy link
Copy Markdown

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.

Files with missing lines Coverage Δ
...earch/SearchList/ListItem/ReportListItemHeader.tsx 89.47% <100.00%> (+0.38%) ⬆️
...h/SearchList/ListItem/TransactionGroupListItem.tsx 74.34% <100.00%> (+1.42%) ⬆️
...arch/SearchList/ListItem/useLiveRowCapabilities.ts 100.00% <100.00%> (ø)
src/components/Search/SearchList/index.tsx 56.57% <ø> (-0.25%) ⬇️
src/components/Search/SearchStaticList.tsx 4.21% <ø> (ø)
src/components/Search/index.tsx 0.59% <ø> (+<0.01%) ⬆️
src/hooks/useSearchBulkActions.ts 46.92% <100.00%> (ø)
src/hooks/useSearchSections.ts 100.00% <ø> (ø)
src/libs/ReportActionsUtils.ts 77.63% <ø> (ø)
src/libs/ReportUtils.ts 83.45% <ø> (+0.17%) ⬆️
... and 13 more
... and 8 files with indirect coverage changes

# Conflicts:
#	src/components/Search/SearchList/ListItem/ChatListItem.tsx
@adhorodyski

Copy link
Copy Markdown
Contributor Author

@codex review

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

Copy link
Copy Markdown

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: 8d7b8334fd

ℹ️ 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 thread src/components/Search/SearchList/ListItem/ExpenseReportListItem.tsx Outdated
@mountiny mountiny requested a review from Copilot May 30, 2026 07:48
@mountiny mountiny marked this pull request as ready for review May 30, 2026 07:48
@mountiny mountiny requested review from a team as code owners May 30, 2026 07:48
@melvin-bot melvin-bot Bot requested review from flaviadefaria and ikevin127 May 30, 2026 07:48
adhorodyski and others added 3 commits June 1, 2026 15:48
- Derive expense-report row selection from child transactions, not the
  report key, so non-empty reports reflect selection (was always false).
- Apply the same primaryActionExclusions as getSections in the live
  recompute via new getPrimaryAction, so the live Action button matches
  the snapshot (SUBMIT hidden for non-owners; PAY hidden as primary for
  non-reimbursable-only reports).
- Read refreshed holdItem.canPay instead of stale snapshot allActions in
  the hold-menu nonHeldAmount math.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
const liveActionsArray = liveReportActions ? (Object.values(liveReportActions) as ReportAction[]) : snapshotActions;
const liveAllActions = getActions(
snapshotData,
snapshotData as unknown as OnyxCollection<TransactionViolation[]>,

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.

⚠️ Unsafe double-cast in useLiveRowCapabilities.ts

Why it matters: snapshotData is SearchResults['data'] | undefined. Casting it through unknown to OnyxCollection<TransactionViolation[]> bypasses the type system entirely. If getActions ever changes its parameter shape, this will fail silently at runtime.

Suggested fix: Use a proper type guard or typed selector.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the cast entirely. Added a typed getViolationsFromSearchData helper in SearchUIUtils that narrows data to violation keys via the existing isViolationEntry guard — getActions now receives a properly-typed OnyxCollection<TransactionViolation[]>, no unknown hop.

if (selectedTransactions[key]?.isSelected) {
selected += 1;
}
}

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.

⚠️ useSelectionCounts uses for...in without hasOwnProperty guard

Why it matters: for...in iterates over inherited enumerable properties. While selectedTransactions is likely a plain object today, any prototype pollution or future polyfill could leak into this count and show incorrect selection counts in the bulk actions bar.

Suggested fix:

for (const key in selectedTransactions) {
    if (Object.prototype.hasOwnProperty.call(selectedTransactions, key) && selectedTransactions[key]?.isSelected) {
        selected += 1;
    }
}

Or simpler, use Object.entries(selectedTransactions) which is safe and more idiomatic:

const selected = Object.entries(selectedTransactions).filter(([, value]) => value?.isSelected).length;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — switched to Object.values(selectedTransactions).filter((value) => value?.isSelected).length.

* Submit/ChangeApprover. The four booleans stay as primitives at the equality
* guard so React Compiler keeps downstream consumers memoized.
*/
function useLiveRowCapabilities<T extends LiveRowItem>(params: UseLiveRowCapabilitiesParams<T>): T {

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.

⚠️ useLiveRowCapabilities creates N individual Onyx subscriptions per row

Each instance of useLiveRowCapabilities subscribes to:

  • ${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}
  • ${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}

For 100 visible rows, that's 200 additional Onyx subscriptions (vs. the previous single collection-level subscription at the screen level).

Why it matters: While the measurements look good for the tested scenario, react-native-onyx's per-key subscription overhead scales differently than a single collection subscription. On lower-end devices with large lists, this could cause memory pressure and jank when scrolling fast (JIT churn from subscribe/unsubscribe).

Recommended fix / mitigation: This is an architectural trade-off, but consider adding a JSDoc warning about the subscription count:

/**
 * ⚠️ Creates 2 Onyx subscriptions per row. For large lists, consider
 *    capping the number of active subscriptions (e.g., via IntersectionObserver
 *    or windowed subscription).
 */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is intentional — it replaces the screen-level collection merge that re-fired every visible row on any row tap (the regression this PR fixes). The list is virtualized, so subscriptions track on-screen rows, not the full result set; net result is the measured -20.5% commit duration. Added a JSDoc note capturing the trade-off and a windowing escape hatch if a future very large list shows churn.

@ikevin127

Copy link
Copy Markdown
Contributor

📊 Testing

⚠️ Zero tests for the three new hooks

The PR adds:

  • useLiveRowCapabilities.ts (82 lines of logic)
  • useRowSelection (8 lines)
  • useSelectionCounts (11 lines)

Removed: 242-line applySelectionToItem test suite with 18 test cases.
Added: No new tests for the replacements.

Why it matters: applySelectionToItem had comprehensive coverage for edge cases like empty reports, partially selected groups, deleted transactions, and reference identity. The new useRowSelection replicates some of this but forgoes the transaction-level selection logic (now inline in ExpenseReportListItem).

There's no test verifying that selecting all transactions in a report marks the report row as selected, or that the reference-equality optimization in useLiveRowCapabilities works.

Suggested tests to add:

// tests/components/Search/useLiveRowCapabilitiesTest.ts
describe('useLiveRowCapabilities', () => {
    it('returns same reference when capabilities have not changed', () => {
        // verify equality guard in useLiveRowCapabilities
    });
    it('updates action when reportActions Onyx key changes', () => {
        // simulate Pusher update
    });
});

// tests/components/Search/useRowSelectionTest.ts
describe('useRowSelection', () => {
    it('marks row selected when areAllMatchingItemsSelected is true', () => {
        // even if key is not in selectedTransactions
    });
    it('reads per-key state when areAllMatchingItemsSelected is false', () => {
        // normal single-key selection
    });
});

@ikevin127 ikevin127 left a comment

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.

🟢 LGTM

Merge readiness: Conditional -> needs the type-safety cast fixed, for...in loop hardened, and test coverage for the new hooks before I'd feel confident shipping this. See comments above for details 🙌

@melvin-bot melvin-bot Bot requested a review from mountiny June 1, 2026 21:15
@flaviadefaria flaviadefaria removed their request for review June 2, 2026 11:50
@flaviadefaria

Copy link
Copy Markdown
Contributor

No new product considerations - removing my assignment and unsubscribing.

…matic count, hook tests

- Replace unsafe `as unknown as OnyxCollection<TransactionViolation[]>` cast in
  useLiveRowCapabilities with a typed getViolationsFromSearchData helper that
  narrows snapshot data to violation keys via the existing isViolationEntry guard.
- useSelectionCounts: swap for...in for Object.values(...).filter(...).length.
- Document the intentional per-row Onyx subscription trade-off in the hook JSDoc.
- Add tests for useRowSelection, useSelectionCounts, useLiveRowCapabilities, and
  getViolationsFromSearchData.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@adhorodyski

adhorodyski commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

@ikevin127 re: testing — added tests for all three new hooks plus the new helper:

  • useRowSelection — per-key read + the areAllMatchingItemsSelected branch you flagged (row selected even when the key is absent from selectedTransactions).
  • useSelectionCounts — counts only isSelected entries.
  • useLiveRowCapabilities — the reference-identity equality guard (same item ref when capabilities are unchanged), the refreshed-capabilities path, and the disabled short-circuit.
  • getViolationsFromSearchData — the new typed violations helper.

adhorodyski and others added 4 commits June 2, 2026 21:59
Drop the now-unused exports flagged by knip: getAction and
getRowCapabilities stay module-private in SearchUIUtils (internal call
sites unchanged), and the orphaned selectFilteredReportActions helper in
ReportUtils is deleted along with its export (its only consumer was the
screen-level reportActions subscription this PR removed).

Rewrite useRowSelectionTest harnesses with renderHook so they no longer
mutate a captured outer-scope object inside a render component, which
tripped the React Compiler "value cannot be modified" bailout on the new
test file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ESLint no-unused-vars failed on CI for isApprovedAction,
isDynamicExternalWorkflowApproveFailedAction, and
isDynamicExternalWorkflowSubmitFailedAction in ReportUtils.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mountiny

mountiny commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@ikevin127 can you recheck now please?

@ikevin127

Copy link
Copy Markdown
Contributor

@mountiny Yep, all comments were addressed and changes look good for merge 👍

@mountiny mountiny left a comment

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.

only nabs, can you please add those in follow ups?

Comment on lines +13 to +29
type LiveRowItem = {
action?: SearchTransactionAction;
canPay?: boolean;
canApprove?: boolean;
canSubmit?: boolean;
canChangeApprover?: boolean;
allActions?: SearchTransactionAction[];
};

type UseLiveRowCapabilitiesParams<T> = {
item: T;
reportID: string | undefined;
itemKey: string;
snapshotData: SearchResults['data'] | undefined;
snapshotActions: ReportAction[];
enabled: boolean;
};

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.

docs, should we update the ai skill to make sure types always have docs?

Comment thread src/libs/SearchUIUtils.ts
* (SUBMIT hidden for non-owners; PAY hidden as the primary action for non-reimbursable-only reports).
* Used by the row-level live recompute so the live Action button matches the snapshot.
*/
function getPrimaryAction(allActions: SearchTransactionAction[], data: OnyxTypes.SearchResults['data'], key: string, currentAccountID: number): SearchTransactionAction {

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.

I think this is not unit tested

@mountiny mountiny merged commit deabb30 into Expensify:main Jun 4, 2026
33 checks passed
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🚧 @mountiny has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

OSBotify commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/mountiny in version: 9.4.0-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 No help site (App/docs/articles) changes are required for this PR.

I reviewed the full diff against the help-site authoring guidelines, naming conventions, and template. This is a pure performance refactor of the Search screen's internal rendering and data-flow — it changes how the screen computes and renders, not what the user sees or does.

Why no docs update is needed

The help articles document user-facing behavior (features, settings, tabs, buttons, workflows). This PR contains none of those:

  • Internal data-flow only — removes the parallel double-merge of live Onyx data in <Search>/getSections, moves live computation into a per-row useLiveRowCapabilities hook, and replaces the array-valued allActions field with primitive booleans (canPay/canApprove/canSubmit/canChangeApprover).
  • No new or renamed user-facing copy — there are no changes to src/languages/*. The translate references in the diff are pre-existing calls being passed through refactored code, not new strings.
  • No UI labels, buttons, tabs, feature names, or settings changed — the Search experience (Pay/Approve/Submit/Change approver actions, row taps, bulk selection) behaves identically; it's just faster (~20% fewer commits per row tap per the PR's profiler measurements).
  • No changed files under App/docs/articles.

Since user-facing behavior is unchanged, the existing help content remains accurate.

If you believe a specific article should still be updated, reply with @MelvinBot and point me at the article and the behavior you'd like documented.

@adhorodyski, no linked help site PR was created because no documentation changes are required. If you disagree, let me know which behavior changed and I'll draft the update.

@OSBotify

OSBotify commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/Julesssss in version: 9.4.0-7 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

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.

7 participants