From b0081a07c59fc374c921bf8ed0ad16697106981a Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 27 Aug 2026 11:54:18 +0500 Subject: [PATCH 1/5] Duplicate one report per tick when bulk duplicating bulkDuplicateReports built the entire selection in a single synchronous pass. Every eligible expense fires its own API.write, which applies its optimistic data synchronously, so nothing could paint, persist or drain from the request queue until the last expense of the last report had been created. Yield a macrotask between reports so each report's writes are applied and persisted before the next one starts. --- src/libs/actions/IOU/Duplicate.ts | 13 ++++++- tests/actions/IOUTest/DuplicateTest.ts | 51 +++++++++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 16b8390baa7d..44cbe90b8dbf 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1352,7 +1352,7 @@ type BulkDuplicateReportsParams = { conciergeChat: OnyxEntry; }; -function bulkDuplicateReports({ +async function bulkDuplicateReports({ dateFnsLocale, selectedReports: selectedReportsParam, allReports, @@ -1404,7 +1404,16 @@ function bulkDuplicateReports({ transactionsByReportID.set(transaction.reportID, list); } - for (const selectedReport of selectedReportsParam) { + for (const [index, selectedReport] of selectedReportsParam.entries()) { + if (index > 0) { + // Duplicate one report per tick. Every expense fires its own API.write, which applies optimistic data + // synchronously, so building the whole selection in one pass blocks the thread until the last expense is created. + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + const reportID = selectedReport.reportID; if (!reportID) { continue; diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index ebe2d1225a45..2f103152c4c9 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -3623,13 +3623,44 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1', 'rpt2'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1', 'rpt2'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(2); expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(2); }); + it('should not duplicate every selected report in a single synchronous pass', async () => { + const reportIDs = ['rpt1', 'rpt2', 'rpt3']; + const allReports: Record = { + [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, + }; + + for (const reportID of reportIDs) { + allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = { + reportID, + policyID: SOURCE_POLICY_ID, + ownerAccountID: RORY_ACCOUNT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + reportName: `Report ${reportID}`, + chatReportID: ACTIVE_PEC_REPORT_ID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}tx-${reportID}`, createCashTransaction(`1${reportID.slice(-1)}`, reportID)); + } + + const duplicating = bulkDuplicateReports(getDefaultBulkParams(reportIDs, {allReports})); + + // The loop must have yielded after the first report, so the rest of the selection is still unprocessed here. + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + + await duplicating; + await waitForBatchedUpdates(); + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(3); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(3); + }); + it('should use source policy when accessible, and fall back to default policy when not', async () => { const chatForSource: Report = { reportID: 'chatSource', @@ -3671,7 +3702,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports( + await bulkDuplicateReports( getDefaultBulkParams(['rpt1', 'rpt2'], { allReports, allPolicyCategories: { @@ -3715,7 +3746,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); @@ -3740,7 +3771,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); @@ -3776,7 +3807,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); @@ -3804,7 +3835,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1', 'nonexistent1', 'nonexistent2'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1', 'nonexistent1', 'nonexistent2'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); @@ -3826,7 +3857,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); @@ -3864,7 +3895,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1', 'rpt2'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1', 'rpt2'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(2); @@ -3918,7 +3949,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports( + await bulkDuplicateReports( getDefaultBulkParams(['rpt1', 'rpt2'], { allReports, allPolicyTags: { @@ -3958,7 +3989,7 @@ describe('actions/Duplicate', () => { [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, }; - bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); + await bulkDuplicateReports(getDefaultBulkParams(['rpt1'], {allReports})); await waitForBatchedUpdates(); expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); From 933b68a2ffb2008b87953881e3183594468129d7 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 27 Aug 2026 12:18:14 +0500 Subject: [PATCH 2/5] Only yield once a report has actually been duplicated Move the yield below the guards that skip a selected report, so entries with no reportID and entries with no matching report no longer cost a macrotask each for work that never happens. Handle the rejection at the call site as well. Now that the action is async a throw surfaces as a rejected promise rather than propagating out of the press handler, so log it instead of dropping it. --- src/hooks/useBulkDuplicateReportAction.ts | 3 +++ src/libs/actions/IOU/Duplicate.ts | 22 +++++++++++-------- .../hooks/useBulkDuplicateReportActionTest.ts | 2 +- .../useSearchBulkActionsDuplicateTest.ts | 2 +- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/hooks/useBulkDuplicateReportAction.ts b/src/hooks/useBulkDuplicateReportAction.ts index f3c31d00ea90..109999968ea0 100644 --- a/src/hooks/useBulkDuplicateReportAction.ts +++ b/src/hooks/useBulkDuplicateReportAction.ts @@ -2,6 +2,7 @@ import {useSearchSelectionActions} from '@components/Search/SearchContext'; import type {SelectedReports} from '@components/Search/types'; import {bulkDuplicateReports} from '@libs/actions/IOU/Duplicate'; +import Log from '@libs/Log'; import {getPolicyExpenseChat} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -80,6 +81,8 @@ function useBulkDuplicateReportAction({selectedReports, allReports, searchData}: formatPhoneNumber, getCurrencyDecimals, conciergeChat, + }).catch((error: unknown) => { + Log.warn('[useBulkDuplicateReportAction] Failed to duplicate the selected reports', {error}); }); clearSelectedTransactions(undefined, true); diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 44cbe90b8dbf..4d35740038ee 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1404,16 +1404,9 @@ async function bulkDuplicateReports({ transactionsByReportID.set(transaction.reportID, list); } - for (const [index, selectedReport] of selectedReportsParam.entries()) { - if (index > 0) { - // Duplicate one report per tick. Every expense fires its own API.write, which applies optimistic data - // synchronously, so building the whole selection in one pass blocks the thread until the last expense is created. - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - } + let hasDuplicatedReport = false; + for (const selectedReport of selectedReportsParam) { const reportID = selectedReport.reportID; if (!reportID) { continue; @@ -1429,6 +1422,15 @@ async function bulkDuplicateReports({ continue; } + if (hasDuplicatedReport) { + // Duplicate one report per tick. Every expense fires its own API.write, which applies optimistic data + // synchronously, so building the whole selection in one pass blocks the thread until the last expense is created. + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + } + const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; const isSourcePolicyValid = !!reportPolicy && isPolicyAccessible(reportPolicy, currentUserLogin); const chatReportID = report.chatReportID ?? report.parentReportID; @@ -1473,6 +1475,8 @@ async function bulkDuplicateReports({ participantsPolicyTags, conciergeChat, }); + + hasDuplicatedReport = true; } playSound(SOUNDS.DONE); diff --git a/tests/unit/hooks/useBulkDuplicateReportActionTest.ts b/tests/unit/hooks/useBulkDuplicateReportActionTest.ts index 993aec57dfc3..2b36e413be93 100644 --- a/tests/unit/hooks/useBulkDuplicateReportActionTest.ts +++ b/tests/unit/hooks/useBulkDuplicateReportActionTest.ts @@ -16,7 +16,7 @@ import Onyx from 'react-native-onyx'; import createMock from '../../utils/createMock'; jest.mock('@libs/actions/IOU/Duplicate', () => ({ - bulkDuplicateReports: jest.fn(), + bulkDuplicateReports: jest.fn(() => Promise.resolve()), })); jest.mock('@hooks/useLocalize', () => ({ diff --git a/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts b/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts index 4d8d121d6384..91dceb5526ae 100644 --- a/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsDuplicateTest.ts @@ -24,7 +24,7 @@ import createMock from '../../utils/createMock'; jest.mock('@libs/actions/IOU/Duplicate', () => ({ bulkDuplicateExpenses: jest.fn(), - bulkDuplicateReports: jest.fn(), + bulkDuplicateReports: jest.fn(() => Promise.resolve()), })); jest.mock('@libs/actions/Search', () => ({ From 4d3f1052d590c59130c42ea9b1c914d5117408ca Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 27 Aug 2026 12:19:00 +0500 Subject: [PATCH 3/5] Trim comments --- src/libs/actions/IOU/Duplicate.ts | 3 +-- tests/actions/IOUTest/DuplicateTest.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 4d35740038ee..1ee105a1955a 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1423,8 +1423,7 @@ async function bulkDuplicateReports({ } if (hasDuplicatedReport) { - // Duplicate one report per tick. Every expense fires its own API.write, which applies optimistic data - // synchronously, so building the whole selection in one pass blocks the thread until the last expense is created. + // Let the previous report's optimistic writes apply before blocking the thread again. // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => { setTimeout(resolve, 0); diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index 2f103152c4c9..f8a05f2f7bb0 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -3650,7 +3650,6 @@ describe('actions/Duplicate', () => { const duplicating = bulkDuplicateReports(getDefaultBulkParams(reportIDs, {allReports})); - // The loop must have yielded after the first report, so the rest of the selection is still unprocessed here. expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); From 705cefa52e46e14aac258635ebed94fde140b5f9 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 27 Aug 2026 16:25:28 +0500 Subject: [PATCH 4/5] Stop duplicating when the account changes mid-flight Yielding between reports lets the user sign out before the loop resumes. Signing out clears Onyx, so the reports, policies and identity captured when the loop started belong to an account that is no longer active, and the remaining duplicateReport() calls would write that data under the newly active session. Capture the account on entry and bail out after a yield if it changed. --- src/libs/actions/IOU/Duplicate.ts | 9 +++++++- src/libs/actions/IOU/index.ts | 5 +++++ tests/actions/IOUTest/DuplicateTest.ts | 31 ++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 1ee105a1955a..930805397d55 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -63,7 +63,7 @@ import type {PerDiemExpenseInformation} from './PerDiem'; import type {CreateDistanceRequestInformation} from './Split'; import type {CreateTrackExpenseParams} from './TrackExpense'; -import {getAllReports, getAllTransactions} from '.'; +import {getAllReports, getAllTransactions, getCurrentUserAccountIDFromSession} from '.'; import {getCleanUpTransactionThreadReportOnyxData} from './DeleteMoneyRequest'; import {getMoneyRequestParticipantsFromReport} from './MoneyRequest'; import {submitPerDiemExpense} from './PerDiem'; @@ -1405,6 +1405,7 @@ async function bulkDuplicateReports({ } let hasDuplicatedReport = false; + const accountIDAtStart = getCurrentUserAccountIDFromSession(); for (const selectedReport of selectedReportsParam) { const reportID = selectedReport.reportID; @@ -1428,6 +1429,12 @@ async function bulkDuplicateReports({ await new Promise((resolve) => { setTimeout(resolve, 0); }); + + // Signing out clears Onyx, so the reports and policies captured above belong to an account that is no + // longer active. Duplicating the rest of them would write the previous account's data under the new one. + if (getCurrentUserAccountIDFromSession() !== accountIDAtStart) { + return; + } } const reportPolicy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`]; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 7a72470aa2ed..52eaecfaad3f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -169,6 +169,10 @@ function getCurrentUserPersonalDetails(): OnyxEntry { return deprecatedCurrentUserPersonalDetails; } +function getCurrentUserAccountIDFromSession(): number { + return deprecatedUserAccountID; +} + function getRecentAttendees(): OnyxEntry { return recentAttendees; } @@ -191,6 +195,7 @@ export { getAllReportNameValuePairs, getAllTransactionDrafts, getCurrentUserPersonalDetails, + getCurrentUserAccountIDFromSession, getRecentAttendees, getAllSnapshots, getSearchQueryByHash, diff --git a/tests/actions/IOUTest/DuplicateTest.ts b/tests/actions/IOUTest/DuplicateTest.ts index f8a05f2f7bb0..e3fbfad7ec28 100644 --- a/tests/actions/IOUTest/DuplicateTest.ts +++ b/tests/actions/IOUTest/DuplicateTest.ts @@ -3660,6 +3660,37 @@ describe('actions/Duplicate', () => { expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(3); }); + it('should stop duplicating the remaining reports when the account changes mid-flight', async () => { + await Onyx.merge(ONYXKEYS.SESSION, {accountID: RORY_ACCOUNT_ID}); + await waitForBatchedUpdates(); + + const reportIDs = ['rpt1', 'rpt2', 'rpt3']; + const allReports: Record = { + [`${ONYXKEYS.COLLECTION.REPORT}${ACTIVE_PEC_REPORT_ID}`]: activePolicyExpenseChat, + }; + for (const reportID of reportIDs) { + allReports[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] = { + reportID, + policyID: SOURCE_POLICY_ID, + ownerAccountID: RORY_ACCOUNT_ID, + type: CONST.REPORT.TYPE.EXPENSE, + reportName: `Report ${reportID}`, + chatReportID: ACTIVE_PEC_REPORT_ID, + }; + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}tx-${reportID}`, createCashTransaction(`1${reportID.slice(-1)}`, reportID)); + } + + const duplicating = bulkDuplicateReports(getDefaultBulkParams(reportIDs, {allReports})); + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + + await Onyx.merge(ONYXKEYS.SESSION, {accountID: RORY_ACCOUNT_ID + 1}); + await waitForBatchedUpdates(); + await duplicating; + + expect(countWriteCommandCalls(WRITE_COMMANDS.CREATE_APP_REPORT)).toBe(1); + expect(countWriteCommandCalls(WRITE_COMMANDS.REQUEST_MONEY)).toBe(1); + }); + it('should use source policy when accessible, and fall back to default policy when not', async () => { const chatForSource: Report = { reportID: 'chatSource', From 33f2925142581c34f328dec35bce08e168117d28 Mon Sep 17 00:00:00 2001 From: Mukher Date: Fri, 28 Aug 2026 09:37:54 +0500 Subject: [PATCH 5/5] Note the yield is temporary until the backend command lands --- src/libs/actions/IOU/Duplicate.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libs/actions/IOU/Duplicate.ts b/src/libs/actions/IOU/Duplicate.ts index 930805397d55..eb886b0f9977 100644 --- a/src/libs/actions/IOU/Duplicate.ts +++ b/src/libs/actions/IOU/Duplicate.ts @@ -1424,7 +1424,8 @@ async function bulkDuplicateReports({ } if (hasDuplicatedReport) { - // Let the previous report's optimistic writes apply before blocking the thread again. + // Temporary until the backend exposes a single command that duplicates a whole selection. Until then, + // let the previous report's optimistic writes apply before blocking the thread again. // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => { setTimeout(resolve, 0);