Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 49 additions & 20 deletions src/libs/actions/IOU/BulkEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import type {TransactionDetails} from '@libs/ReportUtils';
import {
buildOptimisticCreatedReportAction,
buildOptimisticModifiedExpenseReportAction,
buildTransactionThread,
canEditFieldOfMoneyRequest,
findSelfDMReportID,
generateReportID,
getOutstandingChildRequest,
getParsedComment,
getTransactionDetails,
Expand All @@ -24,7 +26,6 @@ import {
} from '@libs/ReportUtils';
import {calculateTaxAmount, getAmount, getClearedPendingFields, getCurrency, getTaxValue, getUpdatedTransaction, isOnHold, isSplitChildTransaction} from '@libs/TransactionUtils';
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
import {createTransactionThreadReport} from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
Expand Down Expand Up @@ -79,9 +80,6 @@ type UpdateMultipleMoneyRequestsParams = {
policyTags: OnyxCollection<OnyxTypes.PolicyTagLists>;
hash?: number;
allPolicies?: OnyxCollection<OnyxTypes.Policy>;
introSelected: OnyxEntry<OnyxTypes.IntroSelected>;
betas: OnyxEntry<OnyxTypes.Beta[]>;
currentUserLogin: string;
currentUserAccountID: number;
delegateAccountID: number | undefined;
};
Expand All @@ -97,10 +95,7 @@ function updateMultipleMoneyRequests({
policyTags,
hash,
allPolicies,
introSelected,
betas,
currentUserAccountID,
currentUserLogin,
delegateAccountID,
}: UpdateMultipleMoneyRequestsParams) {
// Track running totals per report so multiple edits in the same report compound correctly.
Expand Down Expand Up @@ -132,23 +127,20 @@ function updateMultipleMoneyRequests({
}
}

let transactionThreadReportID = transaction.transactionThreadReportID ?? reportAction?.childReportID;
let transactionThreadReportID = reportAction?.childReportID ?? transaction.transactionThreadReportID;
let transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null;

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

// If we created the transaction thread optimistically above, seed it into Onyx
// so the MODIFIED_EXPENSE action has somewhere to land. On success the server's
// OpenReport data (triggered by UpdateMoneyRequest) will overwrite these values.
// Also link the thread back: set childReportID on the parent IOU action and
// transactionThreadReportID on the transaction so subsequent offline edits of the
// same expense reuse this thread instead of generating a new one each time.
if (didCreateThreadInThisIteration && transactionThread && transactionThreadReportID) {
optimisticData.push({
onyxMethod: Onyx.METHOD.SET,
key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
value: transactionThread,
});
Comment on lines +298 to +302

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

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.

@MelvinBot Yeah... Good point. Please link newly created thread back to parent IOU action as shared in review comments.

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.

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:

  1. Optimistic: Merge childReportID: transactionThreadReportID onto the parent IOU report action in REPORT_ACTIONS
  2. Failure: Roll back childReportID to null

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.)

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

  1. Merge childReportID onto the parent report action
  2. Merge transactionThreadReportID onto the transaction

marcaaron — this is worth addressing before merge. I can implement the fix if you'd like (add me as assignee and reply).

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.

@MelvinBot proceed with the fix.

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 — pushed 248ea3d. When didCreateThreadInThisIteration is true, the optimistic data now:

  1. Merges childReportID onto the parent IOU report action
  2. Includes transactionThreadReportID in the transaction optimistic update

Both have failure rollbacks. All 25 BulkEdit tests pass.

// Link childReportID on the parent IOU report action
if (reportAction?.reportActionID && iouReport?.reportID) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
value: {[reportAction.reportActionID]: {childReportID: transactionThreadReportID}},
});
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`,
value: {[reportAction.reportActionID]: {childReportID: null}},
});
}
failureData.push({
onyxMethod: Onyx.METHOD.SET,
key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`,
value: null,
});
}

// Pending fields for the transaction
const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((field) => [field, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE]));
const clearedPendingFields = getClearedPendingFields(transactionChanges);
Expand Down Expand Up @@ -357,6 +381,9 @@ function updateMultipleMoneyRequests({
pendingFields,
isLoading: false,
errorFields: null,
// Link the optimistic thread back to the transaction so subsequent
// offline edits reuse it instead of generating a new thread each time.
...(didCreateThreadInThisIteration && transactionThreadReportID ? {transactionThreadReportID} : {}),
},
});

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

Expand Down
59 changes: 32 additions & 27 deletions src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, {useEffect} from 'react';
import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
import type {ValueOf} from 'type-fest';
import Button from '@components/Button';
Expand Down Expand Up @@ -44,7 +44,7 @@ function SearchEditMultiplePage() {
const {currentSearchHash} = useSearchQueryContext();
const {currentSearchResults} = useSearchResultsContext();
const {clearSelectedTransactions} = useSearchSelectionActions();
const {login: currentUserLogin, accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails();
const delegateAccountID = useDelegateAccountID();
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID);
Expand All @@ -54,8 +54,6 @@ function SearchEditMultiplePage() {
const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS);
const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const [betas] = useOnyx(ONYXKEYS.BETAS);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);

const snapshotData = currentSearchResults?.data;
const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData);
Expand Down Expand Up @@ -127,8 +125,10 @@ function SearchEditMultiplePage() {
};
}, []);

const [isSaving, setIsSaving] = useState(false);

const save = () => {
if (!draftTransaction) {
if (!draftTransaction || isSaving) {
return;
}

Expand Down Expand Up @@ -166,29 +166,32 @@ function SearchEditMultiplePage() {
return;
}

updateMultipleMoneyRequests({
transactionIDs: selectedTransactionIDs,
changes,
policy,
reports: mergedReports,
transactions: mergedTransactions,
reportActions: mergedReportActions,
policyCategories: allPolicyCategories,
policyTags: allPolicyTags,
hash: currentSearchHash,
allPolicies: policies,
introSelected,
betas,
currentUserAccountID,
currentUserLogin: currentUserLogin ?? '',
delegateAccountID,
});
// Bulk edit can start from report (ID-based selection) or search (map-based selection),
// so clear both stores to keep deselection behavior consistent.
clearSelectedTransactions(true);
clearSelectedTransactions();
setIsSaving(true);

// Defer the bulk edit loop so the loading spinner has a chance to paint
// before the synchronous Onyx writes block the JS thread.
requestAnimationFrame(() => {

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.

❌ PERF-12 (docs)

The requestAnimationFrame call is not cancelled if the component unmounts before the callback fires. This means updateMultipleMoneyRequests, clearSelectedTransactions, and Navigation.dismissToPreviousRHP() could execute after the component has unmounted, causing side effects on stale state.

Store the rAF ID and cancel it on unmount, or use a ref to track whether the component is still mounted:

const rafIdRef = useRef<number | null>(null);

useEffect(() => {
    return () => {
        if (rafIdRef.current !== null) {
            cancelAnimationFrame(rafIdRef.current);
        }
    };
}, []);

// In save():
rafIdRef.current = requestAnimationFrame(() => {
    // ... existing logic
    rafIdRef.current = null;
});

Reviewed at: 613f9f5 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

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.

@MelvinBot do you think this comment has merit?

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.

The concern is theoretically valid but practically a no-op here. The rAF callback only calls stateless action functions (updateMultipleMoneyRequests, clearSelectedTransactions, Navigation.dismissToPreviousRHP) — none of them do a setState on this component. If the component unmounts before the rAF fires (e.g. user hits back), these actions either succeed harmlessly or are redundant. There's no leaked reference to component state and no React warning.

That said, it's a very cheap fix (3 lines) and makes the intent clearer. I'd call it nice-to-have rather than must-fix — your call on whether it's worth an extra commit.

@marcaaron marcaaron May 15, 2026

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.

@MelvinBot let's do it.

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.

❌ PERF-12 (docs)

The requestAnimationFrame callback is not cancelled if the component unmounts before it fires. If the user navigates away (e.g., presses back) after setIsSaving(true) but before the rAF executes, the callback will still run and attempt clearSelectedTransactions() and Navigation.dismissToPreviousRHP() on an unmounted component, potentially causing stale operations or navigation glitches.

Store the rAF ID and cancel it on unmount, or cancel it in the existing cleanup effect:

const rafIdRef = useRef<number>();

useEffect(() => {
    return () => {
        if (rafIdRef.current) {
            cancelAnimationFrame(rafIdRef.current);
        }
        clearBulkEditDraftTransaction();
    };
}, []);

// In save():
rafIdRef.current = requestAnimationFrame(() => {
    // ... existing callback
});

Reviewed at: efc4f02 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

updateMultipleMoneyRequests({
transactionIDs: selectedTransactionIDs,
changes,
policy,
reports: mergedReports,
transactions: mergedTransactions,
reportActions: mergedReportActions,
policyCategories: allPolicyCategories,
policyTags: allPolicyTags,
hash: currentSearchHash,
allPolicies: policies,
currentUserAccountID,
delegateAccountID,
});
// Bulk edit can start from report (ID-based selection) or search (map-based selection),
// so clear both stores to keep deselection behavior consistent.
clearSelectedTransactions(true);
clearSelectedTransactions();

Navigation.dismissToPreviousRHP();
Navigation.dismissToPreviousRHP();
});
};

const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD;
Expand Down Expand Up @@ -324,6 +327,8 @@ function SearchEditMultiplePage() {
large
text={translate('common.save')}
onPress={save}
isLoading={isSaving}
isDisabled={isSaving}
style={[styles.m5]}
/>
</View>
Expand Down
Loading
Loading