Fix bulk expense edit performance hang on large reports#90709
Conversation
Replace createTransactionThreadReport (which fires OpenReport API calls) in the bulk edit loop with inline optimistic thread creation using buildTransactionThread + generateReportID. This eliminates up to N redundant OpenReport network requests when editing N expenses. Add a loading spinner on the Save button with requestAnimationFrame deferral so the spinner paints before the synchronous Onyx writes run. Co-authored-by: Marc Glasser <marcaaron@users.noreply.github.com>
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.
|
Co-authored-by: Marc Glasser <marcaaron@users.noreply.github.com>
|
Fixed the failing ESLint check: removed unused |
|
The ESLint failure ( The only remaining failing check is the PR Author Checklist, which requires manual checkbox completion and is not a code issue. All other checks (Jest, TypeScript, perf tests) are still in progress — no further code fix is needed. |
|
@rojiphil can you help Melvin get this PR ready for review? Thanks! |
|
The failing check ( Evidence:
Please re-run the failed jobs to get a green build. |
|
@MelvinBot Please add the following test steps in PR Author checklist: Prerequisite: Test Steps:
Additionally, QA can also verify in inspect window that |
|
Updated the PR description with the requested test steps in the Tests, Offline tests, and QA Steps sections. |
|
The failing check ( Analysis:
This is a flaky test — re-running the CI workflow should resolve it. |
|
@MelvinBot Code changes LGTM. |
|
Done! Ticked all pending checkboxes in the PR Author Checklist and marked the PR as Ready for Review. |
|
@Beamanator 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: cc7f31974a
ℹ️ 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".
| optimisticData.push({ | ||
| onyxMethod: Onyx.METHOD.SET, | ||
| key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, | ||
| value: transactionThread, | ||
| }); |
There was a problem hiding this comment.
Link newly created thread back to parent IOU action
When a transaction has no existing thread, this code only seeds REPORT_<threadID> but never updates the parent IOU report action with childReportID. In this flow, thread discovery still depends on transaction.transactionThreadReportID ?? reportAction.childReportID (see earlier in the same function), so subsequent offline edits of the same expense will generate a new random thread each time and previously added MODIFIED_EXPENSE actions become unreachable from the parent expense report until a server round-trip repairs links. The previous createTransactionThreadReport() path handled this linkage via openReport optimistic data.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
@MelvinBot Yeah... Good point. Please link newly created thread back to parent IOU action as shared in review comments.
There was a problem hiding this comment.
Good catch — the review comment is correct. When createTransactionThreadReport was replaced with inline optimistic thread creation, the childReportID linkage on the parent IOU action was lost.
What's missing: After creating the optimistic thread at BulkEdit.ts:139-147, the optimistic data block at BulkEdit.ts:289-303 only seeds the thread report into Onyx but never merges childReportID onto the parent report action. The reference pattern at Transaction.ts:1356-1379 shows the correct approach.
Fix needed inside the if (didCreateThreadInThisIteration ...) block:
- Optimistic: Merge
childReportID: transactionThreadReportIDonto the parent IOU report action inREPORT_ACTIONS - Failure: Roll back
childReportIDtonull
marcaaron — should I implement this fix? (I can't push changes at rojiphil's request since they aren't the author, assignee, or co-author on this PR.)
There was a problem hiding this comment.
This is a valid catch. The optimistic thread creation here seeds REPORT_<threadID> but doesn't write childReportID back to the parent report action or transactionThreadReportID on the transaction. The old createTransactionThreadReport path handled this via openReport optimistic data.
Within a single bulk edit invocation this isn't an issue (each transaction is processed once), but if the user triggers two separate bulk edits while offline on threadless expenses, the lookup at line 128 would again find both fields undefined and generate a duplicate thread.
The fix should add two more optimistic updates when didCreateThreadInThisIteration is true:
- Merge
childReportIDonto the parent report action - Merge
transactionThreadReportIDonto the transaction
marcaaron — this is worth addressing before merge. I can implement the fix if you'd like (add me as assignee and reply).
There was a problem hiding this comment.
Done — pushed 248ea3d. When didCreateThreadInThisIteration is true, the optimistic data now:
- Merges
childReportIDonto the parent IOU report action - Includes
transactionThreadReportIDin the transaction optimistic update
Both have failure rollbacks. All 25 BulkEdit tests pass.
…formanceHang Co-authored-by: Marc Glasser <marcaaron@users.noreply.github.com>
|
The failing The other failing check ( |
|
@Beamanator 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] |
|
Conflicts resolved. Merged
CI should re-run now. |
|
@MelvinBot Please resolve conflicts |
Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
|
Resolved merge conflicts with |
|
type check failing :'( |
@MelvinBot Are |
|
No — |
@MelvinBot Let's remove them then |
…pleMoneyRequests call Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
|
Removed |
|
@Beamanator There was a minor hiccup with MelvinBot's latest merge that caused ts check failure. |
Beamanator
left a comment
There was a problem hiding this comment.
Approving in case the transactionThreadReportID cleanup is not needed, but commenting about it just in case :D
| ...transaction, | ||
| pendingFields: clearedPendingFields, | ||
| errorFields, | ||
| }, |
There was a problem hiding this comment.
Do we need to also clear out the added transactionThreadReportID in failure data? We potentially add this on line 386 so should we also clear it here if we added it? Something like:
| ...(didCreateThreadInThisIteration ? {transactionThreadReportID: null} : {}) | |
| }, |
There was a problem hiding this comment.
Do we need to also clear out the added
transactionThreadReportIDin failure data?
Yes. We need to. @MelvinBot Since we are optimistically adding transactionThreadReportID, let us clear transactionThreadReportID on failure.
There was a problem hiding this comment.
Done — pushed 03db3fc. When didCreateThreadInThisIteration is true, the failure data now explicitly sets transactionThreadReportID: null on the transaction so the optimistically added thread reference doesn't persist after a failed request.
There was a problem hiding this comment.
NAB - Could be nice to get new tests instead of only removing tests 🤷 maybe in a followup if possible?
There was a problem hiding this comment.
@MelvinBot Please add tests for the following:
- Optimistic thread creation when neither childReportID nor transactionThreadReportID exists
- Priority: childReportID over transactionThreadReportID
…reated Since transactionThreadReportID is optimistically added to the transaction on bulk edit, it needs to be explicitly cleared on failure so a stale thread reference doesn't persist when the server never created the thread. Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
- Test that an optimistic transaction thread is created when neither childReportID nor transactionThreadReportID exists, and the thread is linked back to the transaction via transactionThreadReportID. - Test that childReportID on the report action takes priority over transactionThreadReportID on the transaction when resolving which thread to use. Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
|
Added two new tests in 01c5981:
All 27 tests pass. |
|
@MelvinBot On adding test cases, we have got lint and ts check failures. Please address these failures. |
- Add block-level eslint-disable for no-unsafe-assignment and no-unsafe-member-access in test assertion blocks using `as any` - Add missing `created` property to report action mock object - Add OnyxCollection and ReportActions type imports Co-authored-by: Roji Philip <rojiphil@users.noreply.github.com>
|
Fixed the ESLint and TypeScript failures in e206f40:
|
|
@Beamanator LGTM. Over to you again for review after adding test cases. |
|
Thanks for adding! |
|
🚧 @Beamanator has triggered a test Expensify/App build. You can view the workflow run here. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
Explanation of Change
The bulk expense edit flow (
updateMultipleMoneyRequests) calledcreateTransactionThreadReportfor each expense that lacked a local transaction thread. That function fires anOpenReportAPI command per expense, so editing N expenses on a large report enqueued up to 2·N requests (NUpdateMoneyRequest+ NOpenReport). The synchronous Onyx writes from all thoseopenReportcalls are what hung the main thread.Fix 1 — Remove redundant OpenReport calls: Replace
createTransactionThreadReportwith inline optimistic thread creation usingbuildTransactionThread+generateReportID. This seeds the thread into Onyx purely locally (via theUPDATE_MONEY_REQUESToptimistic data), without firing any API command. The backend already creates the transaction thread when processingUpdateMoneyRequest, so no functional regression — the next naturalopenReportcall (when the user opens the thread) will converge server data.Fix 2 — Add loading spinner on Save button: The Save button now shows a loading spinner (
isLoading/isDisabled) and defers the bulk edit loop viarequestAnimationFrameso the spinner paints before the synchronous Onyx writes block the JS thread. This prevents users from clicking Save multiple times while edits are processing.Fixed Issues
$ #90707
Tests
Prerequisite: Create 40+ reports with at least one expense
Edit multiplemenu option is available for selected expenses.Edit multiplemenu option for selected expenses.Savein theEdit multiple expensespage.Savebutton and is disabledAdditionally, QA can also verify in inspect window that
openReportAPIs are not called due to updates to the expense reportOffline tests
Covered in the test steps above (steps 3-9 are performed offline).
QA Steps
Prerequisite: Create 40+ reports with at least one expense
Edit multiplemenu option is available for selected expenses.Edit multiplemenu option for selected expenses.Savein theEdit multiple expensespage.Savebutton and is disabledAdditionally, QA can also verify in inspect window that
openReportAPIs are not called due to updates to the expense reportPR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, 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.ScrollViewcomponent to make it scrollable when more elements are added to the page.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
AI Tests
npm run lint-changednpm run typecheck-tsgonpm run prettier -- --log-level errornpm run react-compiler-compliance-check check-changednpm test -- --testPathPattern=BulkEditTest