Skip to content

perf: Migrate to Flashlist in MoneyRequestReportTransactionList#91422

Open
TMisiukiewicz wants to merge 77 commits into
Expensify:mainfrom
callstack-internal:perf/virtualised-report-list
Open

perf: Migrate to Flashlist in MoneyRequestReportTransactionList#91422
TMisiukiewicz wants to merge 77 commits into
Expensify:mainfrom
callstack-internal:perf/virtualised-report-list

Conversation

@TMisiukiewicz

@TMisiukiewicz TMisiukiewicz commented May 22, 2026

Copy link
Copy Markdown
Contributor

Explanation of Change

Problem
On heavy money-request reports the RHP list was effectively non-virtualised. The inner transactions section was rendered via plain .map() inside the parent FlatList's ListHeaderComponent, which renders eagerly. Every transaction row mounted synchronously when the report opened, pushing report open time to ~20–30s for a report with 1600 expenses and producing a noticeable jank window on every navigation.

Solution

  1. Unified list — merged the inner transactions section and the outer report-actions section into a single virtualised list. A discriminated-union UnifiedListItem (section-header | transaction | transactions-footer | report-action) flows through one renderItem dispatcher, so transactions and chat messages now share the same recycler window instead of one being eagerly rendered inside the other's header.

  2. FlashList — migrated the list from react-native's FlatList (FlatListWithScrollKey). initialScrollIndex replaces the FlatList-specific useFlatListScrollKey wrapper

  3. Controller + sub-componentMoneyRequestReportTransactionList continues to own the transaction-domain state (sort, group-by, selection, columns, totals, etc.) and exposes it to the parent via a render-prop controller. The parent renders a small MoneyRequestReportUnifiedList sub-component that assembles the FlashList from controller data plus the report-actions list

Fixed Issues

$ #91425
PROPOSAL:

Tests

Before testing: npm run i-standalone as this PR contains patch to the FlashList

  1. Go to Spend tab
  2. Go to Reports tab
  3. Open Report with a couple of expenses - where list is not scrollable
  4. Verify everything is displayed correctly
  5. Open Report with a lot of expenses in multiple categories
  6. Verify report opens correctly
  7. Select a single report and verify it selects correctly
  8. Select all reports from a category using category header and verify they are all selected (even if they are out of the viewport)
  9. Select all reports from all categories using top table header and verify all reports in a list get selected
  10. Send a message on the report and verify you are scrolled to the bottom of the list
  11. Web only: Narrow down the viewport horizontally and verify the table is scrollable horizontally
  12. Send a couple of messages on the chat
  13. Verify that messages are not scrolled horizontally together with the table
  • Verify that no errors appear in the JS console

Offline tests

N/A

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 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.mov
iOS: mWeb Safari
MacOS: Chrome / Safari
web.mov

@codecov

codecov Bot commented May 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/CONST/index.ts 93.33% <ø> (ø)
src/components/Table/TableBody.tsx 100.00% <100.00%> (ø)
...ansactionItemRow/DeferredTransactionItemRowRBR.tsx 100.00% <100.00%> (ø)
...ents/TransactionItemRow/TransactionItemRowWide.tsx 99.14% <100.00%> (+<0.01%) ⬆️
src/components/TransactionItemRow/index.tsx 83.60% <100.00%> (+0.55%) ⬆️
src/hooks/useReportActionsScroll.ts 98.01% <100.00%> (ø)
src/hooks/useScrollToEndOnNewMessageReceived.ts 94.11% <100.00%> (ø)
...stReportView/MoneyRequestReportTransactionItem.tsx 0.00% <0.00%> (ø)
...ts/TransactionItemRow/TransactionItemRowNarrow.tsx 0.00% <0.00%> (ø)
...ew/MoneyRequestReportTransactionLongPressModal.tsx 0.00% <0.00%> (ø)
... and 4 more
... and 19 files with indirect coverage changes

@TMisiukiewicz TMisiukiewicz changed the title perf: virtualise transaction list in report screen perf: Migrate to Flashlist in MoneyRequestReportTransactionList May 26, 2026
@TMisiukiewicz TMisiukiewicz marked this pull request as ready for review May 26, 2026 07:20
@TMisiukiewicz TMisiukiewicz requested review from a team as code owners May 26, 2026 07:20
@melvin-bot melvin-bot Bot requested review from aimane-chnaif and joekaufmanexpensify and removed request for a team and joekaufmanexpensify May 26, 2026 07:20
@melvin-bot

melvin-bot Bot commented May 26, 2026

Copy link
Copy Markdown

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

Comment thread src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx Outdated
Comment thread src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx Outdated

@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: 6b5c9bef51

ℹ️ 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/MoneyRequestReportView/MoneyRequestReportActionsList.tsx Outdated
@github-actions

github-actions Bot commented Jul 2, 2026

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. ⚠️

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

This comment has been minimized.

@TMisiukiewicz TMisiukiewicz force-pushed the perf/virtualised-report-list branch from 8fdb7c6 to a579798 Compare July 2, 2026 12:33
…rt-list

# Conflicts:
#	src/components/Search/index.tsx
@TMisiukiewicz

Copy link
Copy Markdown
Contributor Author

@aimane-chnaif could you do another round of tests please, just remember about reinstalling the packages after switching the branch as you need a patch applied

@TMisiukiewicz TMisiukiewicz force-pushed the perf/virtualised-report-list branch from e8cf35f to 0a8bcf2 Compare July 3, 2026 08:12
@aimane-chnaif

Copy link
Copy Markdown
Contributor

@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: 0a8bcf28b6

ℹ️ 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 +112 to +118
const scrollToBottom = useCallback(() => {
if (lastItemIndexRef.current < 0) {
return;
}

const listRef = reportScrollManager.ref;
listRef?.current?.scrollToIndex({index: lastItemIndexRef.current, animated: false});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route send-message autoscroll through indexed bottom jump

This new helper avoids scrollToEnd because large transaction+chat lists can land on a blank estimated end, but it is only used by the latest-messages pill. When the current user sends an ADD_COMMENT, scrollToBottomForCurrentUserAction still calls reportScrollManager.scrollToEnd(), so the send-message autoscroll keeps the same large-report failure mode after this migration; route that path through this indexed bottom jump as well.

Useful? React with 👍 / 👎.

Comment on lines +123 to +124
const reportActionItems: UnifiedListItem[] = visibleReportActions.map((action) => ({type: 'report-action', action}));
const data: UnifiedListItem[] = shouldInlineTransactions ? [...controller.transactionListItems, TRANSACTIONS_FOOTER_ITEM, ...reportActionItems] : reportActionItems;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Preserve the loading skeleton when only actions are empty

When transactions are inlined, data is populated with transaction rows and the footer even while visibleReportActions is still empty during initial action loading. That prevents the ListEmptyComponent skeleton from rendering, whereas the previous FlatList had empty action data and showed ReportActionsListLoadingSkeleton below the transaction section; reports with existing expenses but still-loading comments now show no loading state for the actions.

Useful? React with 👍 / 👎.

@MelvinBot

Copy link
Copy Markdown
Contributor

Review — FlashList migration for the money-request report list

Reviewed the whole diff, focusing on the parts the bots didn't cover: the new overrideWindowSize windowing mechanism, scroll/deep-link behavior, and the selection re-render path. The architecture is clever and the ExternalScrollFlashListTable approach to keep chat out of the horizontal scroller (fixing the drag-sideways blocker) reads correctly on paper — nice. A few things worth addressing before merge, plus confirmation items best checked on device.

🔴 Deep-link / open-at-action anchoring looks like a real regression

initialScrollIndex is a mount-time-only hint. On a cold deep-link open, the list mounts while visibleReportActions is still empty (isReportEmpty is false during loading because showReportActionsLoadingState is true — MoneyRequestReportActionsList.tsx:720), so linkedActionLocalIndex === -1initialScrollIndex === undefined → opens at top. When the linked action's page hydrates later, FlashList does not re-read the changed prop, and there's no compensating scrollToIndex effect (MoneyRequestReportUnifiedList.tsx:192-193,211). The old useFlatListScrollKey re-evaluated every render and sliced data to the target, so anchoring survived data expansion. This confirms the Codex P1. Recommend a scrollToIndex effect keyed on linkedReportActionID once the action is present in data.

🟠 scrollToEnd blank-bottom bug still lives in two autoscroll paths

The PR added scrollToBottom (indexed jump via scrollToIndex, :112-118) precisely because scrollToEnd lands on an estimated offset and leaves the bottom blank on large lists — but only wired it to the floating "latest messages" pill. New-message autoscroll (:504) and current-user ADD_COMMENT autoscroll (:560, :595) still call reportScrollManager.scrollToEnd(), so sending/receiving a message on a large report can still land on a blank bottom. Route these through the same indexed jump for consistency (matches Codex P2).

🟡 The overrideWindowSize patch works but the invariant is fragile

The patch redirects the window height at a single site (updateLayoutParams); containerViewSizeRef still stores the driver's full content height. getMaxScrollOffset/useBoundDetection correctly read getWindowSize(), and the nested table passes no onEndReached/viewability callbacks, so it's fine today — but any future code path that reads the measured container height for this list would get the full height, not the viewport. Worth a comment in the patch (or the component) calling out that the override is not total. Also overrideWindowSize.width is never read for a vertical list — only .height matters here, so passing contentWidth there is misleading.

Two smaller patch items:

  • patches/@shopify/flash-list/details.md has Upstream PR/issue: TBD, E/App issue: TBD, PR introducing patch: TBD — every other patch entry fills these in. Please attribute before merge.
  • Confirm the patch applies cleanly on a fresh npm install (patch-package hadn't been applied in my checkout).

🟡 Loading skeleton is suppressed when a report has expenses but actions are still loading

When transactions are inlined, data is non-empty (rows + footer) even while visibleReportActions is empty during init (MoneyRequestReportUnifiedList.tsx:124), so ListEmptyComponent (the actions skeleton) never renders. Reports with existing expenses but still-loading comments show no loading state for the chat region (Codex P3, minor UX).

Perf note — selection toggles reconcile the whole unified list

data is rebuilt unconditionally each render (:124), and MoneyRequestReportTransactionList consumes selection context, so a selection toggle rebuilds controller → new data/dispatchRenderItem → FlashList re-processes visible cells. Good news: report-action children bail out (memo'd renderer + selection-independent renderReportAction), so the cost is the reconciliation walk plus re-rendering the visible transaction rows (which are un-memoized — pre-existing, but now worth React.memo-ing since the row is the thing that actually commits). Since React Compiler runs here, the existing "wrap in useMemo" suggestion may be partly redundant — worth a quick DevTools profiler pass on a large report to confirm the toggle cost before deciding.

Minor / FYI (verified, low priority)
  • First-frame under-render, self-correcting: before onLayout, viewportHeight=0/tableOffsetTop=0; the nested list renders only its drawDistance buffer (~24 rows) and corrects on the next commit when both layouts fire. No blank/over-render, but confirm visually it covers a tall viewport on the first frame.
  • inline↔horizontal layout toggle stale offset: the scroll store only resets on report.reportID change and only writes while isHorizontalTable. If a window resize flips inline→horizontal for the same report, the driver seeds from a stale offset until the first post-toggle scroll — possible one-frame wrong-window flash. (MoneyRequestReportUnifiedList.tsx:147-155)
  • scrollToBottom index guard only checks < 0, not an upper clamp to data.length-1 (:113); a data-shrink race could target an out-of-range index. FlashList clamps rather than crashes, so low risk.
  • Verified clean: unread-marker index-offset math (both inline and horizontal cases, no off-by-one at the footer), deleted helpers (getInitialNumReportActionsToRender, getInitialPaginationSize, useFlatListScrollKey, FlatListWithScrollKey) have no dangling references, and the AUTOSCROLL_TO_TOP_THRESHOLD import swap is equivalent.

Automated review by MelvinBot — the deep-link (🔴) and scrollToEnd (🟠) items are the two I'd prioritize; the rest are polish/confirmation. Not a substitute for on-device regression testing of the horizontal-table windowing.

@aimane-chnaif

Copy link
Copy Markdown
Contributor

Please check this deeplink bug. The video shows comparison between this branch vs staging

bug.mov

@TMisiukiewicz

Copy link
Copy Markdown
Contributor Author

@aimane-chnaif any specific repro steps I should follow? I cannot reproduce it unfortunately

bug.mov

@aimane-chnaif

Copy link
Copy Markdown
Contributor

@TMisiukiewicz what is test email? I can invite to that report

@TMisiukiewicz

Copy link
Copy Markdown
Contributor Author

@aimane-chnaif

Copy link
Copy Markdown
Contributor

@aimane-chnaif applausetester@applause.expensifail.com

https://dev.new.expensify.com:8082/r/2606978285575315/7498427359743226882

@TMisiukiewicz

Copy link
Copy Markdown
Contributor Author

@aimane-chnaif this should be fixed, could you retest please?

@aimane-chnaif

Copy link
Copy Markdown
Contributor

Scroll issue fixed but there's still flicker which doesn't happen in production

flicker.mov

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

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

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.

6 participants