Skip to content

Commit abfab70

Browse files
authored
Merge pull request #90709 from Expensify/claude-fixBulkEditPerformanceHang
Fix bulk expense edit performance hang on large reports
2 parents 7470f24 + e206f40 commit abfab70

3 files changed

Lines changed: 255 additions & 116 deletions

File tree

src/libs/actions/IOU/BulkEdit.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import type {TransactionDetails} from '@libs/ReportUtils';
1212
import {
1313
buildOptimisticCreatedReportAction,
1414
buildOptimisticModifiedExpenseReportAction,
15+
buildTransactionThread,
1516
canEditFieldOfMoneyRequest,
1617
findSelfDMReportID,
18+
generateReportID,
1719
getOutstandingChildRequest,
1820
getParsedComment,
1921
getTransactionDetails,
@@ -24,7 +26,6 @@ import {
2426
} from '@libs/ReportUtils';
2527
import {calculateTaxAmount, getAmount, getClearedPendingFields, getCurrency, getTaxValue, getUpdatedTransaction, isOnHold, isSplitChildTransaction} from '@libs/TransactionUtils';
2628
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
27-
import {createTransactionThreadReport} from '@userActions/Report';
2829
import CONST from '@src/CONST';
2930
import ONYXKEYS from '@src/ONYXKEYS';
3031
import type * as OnyxTypes from '@src/types/onyx';
@@ -79,9 +80,6 @@ type UpdateMultipleMoneyRequestsParams = {
7980
policyTags: OnyxCollection<OnyxTypes.PolicyTagLists>;
8081
hash?: number;
8182
allPolicies?: OnyxCollection<OnyxTypes.Policy>;
82-
introSelected: OnyxEntry<OnyxTypes.IntroSelected>;
83-
betas: OnyxEntry<OnyxTypes.Beta[]>;
84-
currentUserLogin: string;
8583
currentUserAccountID: number;
8684
delegateAccountID: number | undefined;
8785
};
@@ -97,10 +95,7 @@ function updateMultipleMoneyRequests({
9795
policyTags,
9896
hash,
9997
allPolicies,
100-
introSelected,
101-
betas,
10298
currentUserAccountID,
103-
currentUserLogin,
10499
delegateAccountID,
105100
}: UpdateMultipleMoneyRequestsParams) {
106101
// Track running totals per report so multiple edits in the same report compound correctly.
@@ -132,23 +127,20 @@ function updateMultipleMoneyRequests({
132127
}
133128
}
134129

135-
let transactionThreadReportID = transaction.transactionThreadReportID ?? reportAction?.childReportID;
130+
let transactionThreadReportID = reportAction?.childReportID ?? transaction.transactionThreadReportID;
136131
let transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null;
137132

138133
// Offline-created expenses can be missing a transaction thread until it's opened once.
139-
// Ensure the thread exists before adding optimistic MODIFIED_EXPENSE actions so
134+
// Ensure the thread exists locally before adding optimistic MODIFIED_EXPENSE actions so
140135
// bulk-edit comments are visible immediately while still offline.
136+
// We intentionally avoid calling createTransactionThreadReport here because it fires
137+
// an OpenReport API command per expense, which floods the queue and hangs the UI on
138+
// large reports (40-50+ expenses). The backend already creates the transaction thread
139+
// when processing UpdateMoneyRequest, so we only need local Onyx state.
141140
let didCreateThreadInThisIteration = false;
142141
if (!transactionThreadReportID && iouReport?.reportID) {
143-
const optimisticTransactionThread = createTransactionThreadReport({
144-
introSelected,
145-
currentUserLogin,
146-
currentUserAccountID,
147-
betas,
148-
iouReport,
149-
iouReportAction: reportAction,
150-
transaction,
151-
});
142+
const optimisticTransactionThreadReportID = generateReportID();
143+
const optimisticTransactionThread = buildTransactionThread(reportAction, iouReport, currentUserAccountID, undefined, optimisticTransactionThreadReportID);
152144
if (optimisticTransactionThread?.reportID) {
153145
transactionThreadReportID = optimisticTransactionThread.reportID;
154146
transactionThread = optimisticTransactionThread;
@@ -296,6 +288,38 @@ function updateMultipleMoneyRequests({
296288
const snapshotSuccessData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SNAPSHOT>> = [];
297289
const snapshotFailureData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.SNAPSHOT>> = [];
298290

291+
// If we created the transaction thread optimistically above, seed it into Onyx
292+
// so the MODIFIED_EXPENSE action has somewhere to land. On success the server's
293+
// OpenReport data (triggered by UpdateMoneyRequest) will overwrite these values.
294+
// Also link the thread back: set childReportID on the parent IOU action and
295+
// transactionThreadReportID on the transaction so subsequent offline edits of the
296+
// same expense reuse this thread instead of generating a new one each time.
297+
if (didCreateThreadInThisIteration && transactionThread && transactionThreadReportID) {
298+
optimisticData.push({
299+
onyxMethod: Onyx.METHOD.SET,
300+
key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
301+
value: transactionThread,
302+
});
303+
// Link childReportID on the parent IOU report action
304+
if (reportAction?.reportActionID && iouReport?.reportID) {
305+
optimisticData.push({
306+
onyxMethod: Onyx.METHOD.MERGE,
307+
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
308+
value: {[reportAction.reportActionID]: {childReportID: transactionThreadReportID}},
309+
});
310+
failureData.push({
311+
onyxMethod: Onyx.METHOD.MERGE,
312+
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
313+
value: {[reportAction.reportActionID]: {childReportID: null}},
314+
});
315+
}
316+
failureData.push({
317+
onyxMethod: Onyx.METHOD.SET,
318+
key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
319+
value: null,
320+
});
321+
}
322+
299323
// Pending fields for the transaction
300324
const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((field) => [field, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE]));
301325
const clearedPendingFields = getClearedPendingFields(transactionChanges);
@@ -357,6 +381,9 @@ function updateMultipleMoneyRequests({
357381
pendingFields,
358382
isLoading: false,
359383
errorFields: null,
384+
// Link the optimistic thread back to the transaction so subsequent
385+
// offline edits reuse it instead of generating a new thread each time.
386+
...(didCreateThreadInThisIteration && transactionThreadReportID ? {transactionThreadReportID} : {}),
360387
},
361388
});
362389

@@ -460,9 +487,8 @@ function updateMultipleMoneyRequests({
460487
if (transactionThreadReportID) {
461488
// Backfill a CREATED action for threads never opened locally so
462489
// MoneyRequestView renders and the skeleton doesn't loop offline.
463-
// Skip when the thread was just created above (openReport handles it).
464490
const threadReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {};
465-
const hasCreatedAction = didCreateThreadInThisIteration || Object.values(threadReportActions).some((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED);
491+
const hasCreatedAction = Object.values(threadReportActions).some((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED);
466492
const optimisticCreatedValue: Record<string, Partial<OnyxTypes.ReportAction>> = {};
467493
if (!hasCreatedAction) {
468494
const optimisticCreatedAction = buildOptimisticCreatedReportAction({emailCreatingAction: CONST.REPORT.OWNER_EMAIL_FAKE});
@@ -528,6 +554,9 @@ function updateMultipleMoneyRequests({
528554
...transaction,
529555
pendingFields: clearedPendingFields,
530556
errorFields,
557+
// Clear the optimistically added transactionThreadReportID so it doesn't
558+
// persist after a failed request — the server never created this thread.
559+
...(didCreateThreadInThisIteration && transactionThreadReportID ? {transactionThreadReportID: null} : {}),
531560
},
532561
});
533562

src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, {useEffect} from 'react';
1+
import React, {useEffect, useState} from 'react';
22
import {View} from 'react-native';
33
import type {ValueOf} from 'type-fest';
44
import Button from '@components/Button';
@@ -44,7 +44,7 @@ function SearchEditMultiplePage() {
4444
const {currentSearchHash} = useSearchQueryContext();
4545
const {currentSearchResults} = useSearchResultsContext();
4646
const {clearSelectedTransactions} = useSearchSelectionActions();
47-
const {login: currentUserLogin, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
47+
const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
4848
const delegateAccountID = useDelegateAccountID();
4949
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
5050
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
@@ -54,8 +54,6 @@ function SearchEditMultiplePage() {
5454
const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
5555
const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
5656
const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
57-
const [betas] = useOnyx(ONYXKEYS.BETAS);
58-
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
5957

6058
const snapshotData = currentSearchResults?.data;
6159
const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData);
@@ -127,8 +125,10 @@ function SearchEditMultiplePage() {
127125
};
128126
}, []);
129127

128+
const [isSaving, setIsSaving] = useState(false);
129+
130130
const save = () => {
131-
if (!draftTransaction) {
131+
if (!draftTransaction || isSaving) {
132132
return;
133133
}
134134

@@ -166,29 +166,32 @@ function SearchEditMultiplePage() {
166166
return;
167167
}
168168

169-
updateMultipleMoneyRequests({
170-
transactionIDs: selectedTransactionIDs,
171-
changes,
172-
policy,
173-
reports: mergedReports,
174-
transactions: mergedTransactions,
175-
reportActions: mergedReportActions,
176-
policyCategories: allPolicyCategories,
177-
policyTags: allPolicyTags,
178-
hash: currentSearchHash,
179-
allPolicies: policies,
180-
introSelected,
181-
betas,
182-
currentUserAccountID,
183-
currentUserLogin: currentUserLogin ?? '',
184-
delegateAccountID,
185-
});
186-
// Bulk edit can start from report (ID-based selection) or search (map-based selection),
187-
// so clear both stores to keep deselection behavior consistent.
188-
clearSelectedTransactions(true);
189-
clearSelectedTransactions();
169+
setIsSaving(true);
170+
171+
// Defer the bulk edit loop so the loading spinner has a chance to paint
172+
// before the synchronous Onyx writes block the JS thread.
173+
requestAnimationFrame(() => {
174+
updateMultipleMoneyRequests({
175+
transactionIDs: selectedTransactionIDs,
176+
changes,
177+
policy,
178+
reports: mergedReports,
179+
transactions: mergedTransactions,
180+
reportActions: mergedReportActions,
181+
policyCategories: allPolicyCategories,
182+
policyTags: allPolicyTags,
183+
hash: currentSearchHash,
184+
allPolicies: policies,
185+
currentUserAccountID,
186+
delegateAccountID,
187+
});
188+
// Bulk edit can start from report (ID-based selection) or search (map-based selection),
189+
// so clear both stores to keep deselection behavior consistent.
190+
clearSelectedTransactions(true);
191+
clearSelectedTransactions();
190192

191-
Navigation.dismissToPreviousRHP();
193+
Navigation.dismissToPreviousRHP();
194+
});
192195
};
193196

194197
const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD;
@@ -324,6 +327,8 @@ function SearchEditMultiplePage() {
324327
large
325328
text={translate('common.save')}
326329
onPress={save}
330+
isLoading={isSaving}
331+
isDisabled={isSaving}
327332
style={[styles.m5]}
328333
/>
329334
</View>

0 commit comments

Comments
 (0)