From 94e4e14488037ae55cea2744b9c6337c393348dd Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 24 Jun 2026 10:26:52 +0200 Subject: [PATCH 01/11] fix: set transaction reportID early in useResetIOUType for global-create --- src/hooks/useResetIOUType.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index c533f6fca50d..98a6322b7a96 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -6,6 +6,7 @@ import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {getIsFromGlobalCreate} from '@libs/TransactionUtils'; import {initMoneyRequest} from '@userActions/IOU/MoneyRequest'; +import {setTransactionReport} from '@userActions/Transaction'; import type {IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -127,6 +128,18 @@ function useResetIOUType({ defaultParticipants, }); + // Set the transaction reportID early for global-create flows with resolved default + // participants. This ensures destinationReportID is defined from the confirmation's + // first render, preventing the pre-insert useEffect from seeing an undefined-to-value + // transition that would tear down and fail to re-fire the pre-insert. + if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { + const firstParticipant = defaultParticipants.at(0); + const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; + if (resolvedReportID) { + setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); + } + } + // Layer odometer draft fields onto the freshly-rebuilt transaction. The merge queues after // initMoneyRequest's Onyx.set, so the odometer fields land on top. hydrateOdometerOnLanding(newIOUType); From f7f394ccb2efe79f7741c2f611c0111f18840962 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 24 Jun 2026 10:27:13 +0200 Subject: [PATCH 02/11] fix: reset hasPreInsertFired on cleanup to allow re-fire on dependency change --- .../request/step/IOURequestStepConfirmation.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 50d03f2ff167..9c39d2192459 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -500,14 +500,22 @@ function IOURequestStepConfirmation({ } Navigation.removePreInsertedFullscreenIfNeeded(); + + // Allow the pre-insert to re-fire when dependencies change (e.g. destinationReportID + // transitions from undefined to a valid ID after setTransactionReport resolves). + // Without this reset, the guard would permanently block re-firing after the first + // pre-insert was torn down due to a dependency change. + hasPreInsertFired.current = false; }; // isFromGlobalCreate, iouType, and canPreInsertSearch are stable for the lifetime of // this screen instance. isTransactionReady and destinationReportID may each flip once // (false -> true / undefined -> ID) as data loads asynchronously, re-triggering the effect. - // hasPreInsertFired prevents double-firing. Note: if destinationReportID were to change - // from one valid ID to another (extremely unlikely with Onyx), the pre-insert would not - // re-fire. This is acceptable because the pre-inserted route is already correct for - // the original destination, and the submit handler will navigate correctly regardless. + // The hasPreInsertFired reset enables at most one additional re-fire when + // destinationReportID transitions from undefined to a valid ID. If destinationReportID + // were to change from one valid ID to another (extremely unlikely with Onyx), the + // pre-insert would re-fire once more, which is acceptable since the cleanup removes the + // stale route first. Oscillation is not possible because setTransactionReport only fires + // once during resetIOUTypeIfChanged. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isTransactionReady, destinationReportID]); From d4357f0f1b91dbf82bc772a09361f5649630246c Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 24 Jun 2026 10:29:27 +0200 Subject: [PATCH 03/11] test: add tests for early reportID resolution in useResetIOUType --- tests/unit/useResetIOUTypeReportIDTest.ts | 71 +++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/unit/useResetIOUTypeReportIDTest.ts diff --git a/tests/unit/useResetIOUTypeReportIDTest.ts b/tests/unit/useResetIOUTypeReportIDTest.ts new file mode 100644 index 000000000000..59185363a9d3 --- /dev/null +++ b/tests/unit/useResetIOUTypeReportIDTest.ts @@ -0,0 +1,71 @@ +import {setTransactionReport} from '@userActions/Transaction'; +import CONST from '@src/CONST'; + +jest.mock('@userActions/Transaction', () => ({ + setTransactionReport: jest.fn(), +})); + +const mockSetTransactionReport = jest.mocked(setTransactionReport); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('useResetIOUType - early reportID setting', () => { + it('calls setTransactionReport with workspace reportID for workspace participants', () => { + const defaultParticipants = [{accountID: 0, reportID: 'workspace-chat-123', isPolicyExpenseChat: true, selected: true, isSelfDM: false}]; + const isFromGlobalCreate = true; + + // Simulate the logic from useResetIOUType + if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { + const firstParticipant = defaultParticipants.at(0); + const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; + if (resolvedReportID) { + setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); + } + } + + expect(mockSetTransactionReport).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: 'workspace-chat-123'}, true); + }); + + it('calls setTransactionReport with UNREPORTED_REPORT_ID for selfDM participants', () => { + const defaultParticipants = [{accountID: 0, reportID: 'self-dm-456', isSelfDM: true, selected: true}]; + const isFromGlobalCreate = true; + + if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { + const firstParticipant = defaultParticipants.at(0); + const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; + if (resolvedReportID) { + setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); + } + } + + expect(mockSetTransactionReport).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); + }); + + it('does not call setTransactionReport when not from global create', () => { + const defaultParticipants = [{accountID: 0, reportID: 'some-report', selected: true}]; + const isFromGlobalCreate = false; + + if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { + const firstParticipant = defaultParticipants.at(0); + const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; + if (resolvedReportID) { + setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); + } + } + + expect(mockSetTransactionReport).not.toHaveBeenCalled(); + }); + + it('does not call setTransactionReport when no default participants', () => { + const defaultParticipants = undefined; + const isFromGlobalCreate = true; + + if (isFromGlobalCreate && defaultParticipants && (defaultParticipants as unknown[]).length > 0) { + setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: 'should-not-reach'}, true); + } + + expect(mockSetTransactionReport).not.toHaveBeenCalled(); + }); +}); From 692904c64eec81967a6a486c05be0c3d06ac8ad3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 24 Jun 2026 10:40:51 +0200 Subject: [PATCH 04/11] refactor: extract resolveEarlyReportID as testable pure function --- src/hooks/useResetIOUType.ts | 27 ++++++-- tests/unit/useResetIOUTypeReportIDTest.ts | 81 ++++++++--------------- 2 files changed, 49 insertions(+), 59 deletions(-) diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index 98a6322b7a96..f948b0f35043 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -11,6 +11,7 @@ import type {IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, Transaction} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDefaultParticipants from './useDefaultParticipants'; import useOdometerDraftHydrator from './useOdometerDraftHydrator'; @@ -132,12 +133,9 @@ function useResetIOUType({ // participants. This ensures destinationReportID is defined from the confirmation's // first render, preventing the pre-insert useEffect from seeing an undefined-to-value // transition that would tear down and fail to re-fire the pre-insert. - if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { - const firstParticipant = defaultParticipants.at(0); - const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; - if (resolvedReportID) { - setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); - } + const earlyReportID = resolveEarlyReportID(isFromGlobalCreate, defaultParticipants); + if (earlyReportID) { + setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: earlyReportID}, true); } // Layer odometer draft fields onto the freshly-rebuilt transaction. The merge queues after @@ -180,4 +178,21 @@ function useResetIOUType({ return onTabSelected; } +/** + * Resolves the reportID that should be set on the transaction draft for + * global-create flows with default participants. Returns undefined when + * no early set is needed (non-global-create or empty participants). + */ +function resolveEarlyReportID(isFromGlobalCreate: boolean, participants: Participant[] | undefined): string | undefined { + if (!isFromGlobalCreate || !participants || participants.length === 0) { + return undefined; + } + const firstParticipant = participants.at(0); + if (firstParticipant?.isSelfDM) { + return CONST.REPORT.UNREPORTED_REPORT_ID; + } + return firstParticipant?.reportID; +} + export default useResetIOUType; +export {resolveEarlyReportID}; diff --git a/tests/unit/useResetIOUTypeReportIDTest.ts b/tests/unit/useResetIOUTypeReportIDTest.ts index 59185363a9d3..e60e2d625b19 100644 --- a/tests/unit/useResetIOUTypeReportIDTest.ts +++ b/tests/unit/useResetIOUTypeReportIDTest.ts @@ -1,71 +1,46 @@ -import {setTransactionReport} from '@userActions/Transaction'; +import {resolveEarlyReportID} from '@hooks/useResetIOUType'; import CONST from '@src/CONST'; +import type {Participant} from '@src/types/onyx/IOU'; -jest.mock('@userActions/Transaction', () => ({ - setTransactionReport: jest.fn(), -})); +describe('resolveEarlyReportID', () => { + it('returns workspace chat reportID for workspace participants', () => { + const participants: Participant[] = [{accountID: 0, reportID: 'workspace-chat-123', isPolicyExpenseChat: true, selected: true}]; -const mockSetTransactionReport = jest.mocked(setTransactionReport); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -describe('useResetIOUType - early reportID setting', () => { - it('calls setTransactionReport with workspace reportID for workspace participants', () => { - const defaultParticipants = [{accountID: 0, reportID: 'workspace-chat-123', isPolicyExpenseChat: true, selected: true, isSelfDM: false}]; - const isFromGlobalCreate = true; + expect(resolveEarlyReportID(true, participants)).toBe('workspace-chat-123'); + }); - // Simulate the logic from useResetIOUType - if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { - const firstParticipant = defaultParticipants.at(0); - const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; - if (resolvedReportID) { - setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); - } - } + it('returns UNREPORTED_REPORT_ID for selfDM participants', () => { + const participants: Participant[] = [{accountID: 0, reportID: 'self-dm-456', isSelfDM: true, selected: true}]; - expect(mockSetTransactionReport).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: 'workspace-chat-123'}, true); + expect(resolveEarlyReportID(true, participants)).toBe(CONST.REPORT.UNREPORTED_REPORT_ID); }); - it('calls setTransactionReport with UNREPORTED_REPORT_ID for selfDM participants', () => { - const defaultParticipants = [{accountID: 0, reportID: 'self-dm-456', isSelfDM: true, selected: true}]; - const isFromGlobalCreate = true; + it('returns undefined when not from global create', () => { + const participants: Participant[] = [{accountID: 0, reportID: 'some-report', selected: true}]; - if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { - const firstParticipant = defaultParticipants.at(0); - const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; - if (resolvedReportID) { - setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); - } - } + expect(resolveEarlyReportID(false, participants)).toBeUndefined(); + }); - expect(mockSetTransactionReport).toHaveBeenCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); + it('returns undefined when participants array is empty', () => { + expect(resolveEarlyReportID(true, [])).toBeUndefined(); }); - it('does not call setTransactionReport when not from global create', () => { - const defaultParticipants = [{accountID: 0, reportID: 'some-report', selected: true}]; - const isFromGlobalCreate = false; + it('returns undefined when participants is undefined', () => { + expect(resolveEarlyReportID(true, undefined)).toBeUndefined(); + }); - if (isFromGlobalCreate && defaultParticipants && defaultParticipants.length > 0) { - const firstParticipant = defaultParticipants.at(0); - const resolvedReportID = firstParticipant?.isSelfDM ? CONST.REPORT.UNREPORTED_REPORT_ID : firstParticipant?.reportID; - if (resolvedReportID) { - setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: resolvedReportID}, true); - } - } + it('returns undefined when first participant has no reportID and is not selfDM', () => { + const participants: Participant[] = [{accountID: 123, selected: true}]; - expect(mockSetTransactionReport).not.toHaveBeenCalled(); + expect(resolveEarlyReportID(true, participants)).toBeUndefined(); }); - it('does not call setTransactionReport when no default participants', () => { - const defaultParticipants = undefined; - const isFromGlobalCreate = true; - - if (isFromGlobalCreate && defaultParticipants && (defaultParticipants as unknown[]).length > 0) { - setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: 'should-not-reach'}, true); - } + it('uses first participant only, ignores subsequent ones', () => { + const participants: Participant[] = [ + {accountID: 0, reportID: 'first-report', isPolicyExpenseChat: true, selected: true}, + {accountID: 0, reportID: 'second-report', isPolicyExpenseChat: true, selected: true}, + ]; - expect(mockSetTransactionReport).not.toHaveBeenCalled(); + expect(resolveEarlyReportID(true, participants)).toBe('first-report'); }); }); From 7340be7108c9e633bf409ef2efbb17812731deb1 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 24 Jun 2026 13:28:11 +0200 Subject: [PATCH 05/11] refactor: move resolveEarlyReportID to IOUUtils --- src/hooks/useResetIOUType.ts | 19 +------------------ src/libs/IOUUtils.ts | 17 +++++++++++++++++ tests/unit/useResetIOUTypeReportIDTest.ts | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index f948b0f35043..5b281a7e2ab5 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -4,6 +4,7 @@ import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft'; import {useRef} from 'react'; import {Keyboard} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; +import {resolveEarlyReportID} from '@libs/IOUUtils'; import {getIsFromGlobalCreate} from '@libs/TransactionUtils'; import {initMoneyRequest} from '@userActions/IOU/MoneyRequest'; import {setTransactionReport} from '@userActions/Transaction'; @@ -11,7 +12,6 @@ import type {IOURequestType, IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, Report, Transaction} from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDefaultParticipants from './useDefaultParticipants'; import useOdometerDraftHydrator from './useOdometerDraftHydrator'; @@ -178,21 +178,4 @@ function useResetIOUType({ return onTabSelected; } -/** - * Resolves the reportID that should be set on the transaction draft for - * global-create flows with default participants. Returns undefined when - * no early set is needed (non-global-create or empty participants). - */ -function resolveEarlyReportID(isFromGlobalCreate: boolean, participants: Participant[] | undefined): string | undefined { - if (!isFromGlobalCreate || !participants || participants.length === 0) { - return undefined; - } - const firstParticipant = participants.at(0); - if (firstParticipant?.isSelfDM) { - return CONST.REPORT.UNREPORTED_REPORT_ID; - } - return firstParticipant?.reportID; -} - export default useResetIOUType; -export {resolveEarlyReportID}; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 52e7acfad9db..44f2ddaf0838 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -521,6 +521,22 @@ function isParticipantP2P(participant: {accountID?: number; isPolicyExpenseChat? return !!(participant?.accountID && !participant.isPolicyExpenseChat && !participant.isSelfDM); } +/** + * Resolves the reportID that should be set on the transaction draft for + * global-create flows with default participants. Returns undefined when + * no early set is needed (non-global-create or empty participants). + */ +function resolveEarlyReportID(isFromGlobalCreate: boolean, participants: Participant[] | undefined): string | undefined { + if (!isFromGlobalCreate || !participants || participants.length === 0) { + return undefined; + } + const firstParticipant = participants.at(0); + if (firstParticipant?.isSelfDM) { + return CONST.REPORT.UNREPORTED_REPORT_ID; + } + return firstParticipant?.reportID; +} + export { calculateAmount, calculateSplitAmountFromPercentage, @@ -542,4 +558,5 @@ export { isParticipantP2P, resolveOptimisticChatReportID, resolveReportForMoneyRequest, + resolveEarlyReportID, }; diff --git a/tests/unit/useResetIOUTypeReportIDTest.ts b/tests/unit/useResetIOUTypeReportIDTest.ts index e60e2d625b19..587bc0047b5f 100644 --- a/tests/unit/useResetIOUTypeReportIDTest.ts +++ b/tests/unit/useResetIOUTypeReportIDTest.ts @@ -1,4 +1,4 @@ -import {resolveEarlyReportID} from '@hooks/useResetIOUType'; +import {resolveEarlyReportID} from '@libs/IOUUtils'; import CONST from '@src/CONST'; import type {Participant} from '@src/types/onyx/IOU'; From 13dbc80987fca952e372dd39937269d0e48345bc Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 25 Jun 2026 11:38:33 +0200 Subject: [PATCH 06/11] fix: self-dms, beta inbox --- .../step/IOURequestStepConfirmation.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 9c39d2192459..c2d9a03986b6 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -318,6 +318,10 @@ function IOURequestStepConfirmation({ navigation.setParams({iouType: CONST.IOU.TYPE.CREATE}); } setMoneyRequestParticipants(activeTransactionID, participantsList); + const firstParticipant = participantsList.at(0); + if (firstParticipant?.reportID) { + setTransactionReport(activeTransactionID, {reportID: firstParticipant.reportID}, true); + } } if (participantsList.length > 0) { closeParticipantPicker(); @@ -340,9 +344,12 @@ function IOURequestStepConfirmation({ } setMoneyRequestParticipants(transaction.transactionID, defaultParticipants); - if (defaultParticipants.at(0)?.isSelfDM) { + const firstDefault = defaultParticipants.at(0); + if (firstDefault?.isSelfDM) { setTransactionReport(transaction.transactionID, {reportID: CONST.REPORT.UNREPORTED_REPORT_ID}, true); navigation.setParams({iouType: CONST.IOU.TYPE.TRACK}); + } else if (firstDefault?.reportID) { + setTransactionReport(transaction.transactionID, {reportID: firstDefault.reportID}, true); } }, [transaction?.transactionID, transaction?.participants, defaultParticipants, isNewManualExpenseFlowEnabled, isManualRequest, navigation]); @@ -392,11 +399,10 @@ function IOURequestStepConfirmation({ backToReport, }); - // PAY, SPLIT, and per-diem TRACK navigate to a specific destination report + // PAY, SPLIT, and TRACK navigate to a specific destination report // (not Search) after submission. Pre-inserting the Search route would leave - // a stale entry in the navigation stack. Non-per-diem TRACK flows can still - // benefit from the Search pre-insert optimization. - const canPreInsertSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT && !(isPerDiemRequest && iouType === CONST.IOU.TYPE.TRACK); + // a stale entry in the navigation stack. + const canPreInsertSearch = iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.SPLIT && iouType !== CONST.IOU.TYPE.TRACK; const {createTransaction, sendMoney, isConfirmed, setIsConfirmed, formHasBeenSubmitted} = useExpenseSubmission({ transaction, @@ -462,7 +468,8 @@ function IOURequestStepConfirmation({ // Only eligible when search pre-insert didn't win, and the flow ends at a report (not Search). // When Search is the topmost fullscreen and there's no report context (e.g. QAB from Spend tab), // pre-inserting a report is wrong - the user should stay on Search after submission. - const canUseReportPreInsert = !shouldPreInsertSearch && (isReportTopmostSplitNavigator() || (!isFromGlobalCreate && !isSearchTopmostFullScreenRoute())); + // Global-create TRACK targets self-DM (a report), so it's also eligible for report pre-insert. + const canUseReportPreInsert = !shouldPreInsertSearch && (isReportTopmostSplitNavigator() || isCreatingTrackExpense || (!isFromGlobalCreate && !isSearchTopmostFullScreenRoute())); // RHP has its own dismiss handler; pre-inserting under it would break the stack. const isOutsideRHP = !isReportOpenInRHP(navigationRef.getRootState()); From 689d6abde306b0bf26272de9f486e2860f9e3664 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 25 Jun 2026 12:13:28 +0200 Subject: [PATCH 07/11] fix: stale pre-inserted flag --- .../iou/request/step/IOURequestStepConfirmation.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index c2d9a03986b6..95fcce75e2fe 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -502,16 +502,20 @@ function IOURequestStepConfirmation({ clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps -- formHasBeenSubmitted is a stable ref from useExpenseSubmission; reading .current in cleanup is intentional - if (!Navigation.getIsFullscreenPreInsertedUnderRHP() || formHasBeenSubmitted.current) { + if (formHasBeenSubmitted.current) { return; } - Navigation.removePreInsertedFullscreenIfNeeded(); + if (Navigation.getIsFullscreenPreInsertedUnderRHP()) { + Navigation.removePreInsertedFullscreenIfNeeded(); + } // Allow the pre-insert to re-fire when dependencies change (e.g. destinationReportID // transitions from undefined to a valid ID after setTransactionReport resolves). // Without this reset, the guard would permanently block re-firing after the first - // pre-insert was torn down due to a dependency change. + // pre-insert was torn down due to a dependency change. This must run even when the + // 300ms timer was cleared before the pre-insert could execute, otherwise the flag + // stays true and blocks all subsequent attempts. hasPreInsertFired.current = false; }; // isFromGlobalCreate, iouType, and canPreInsertSearch are stable for the lifetime of From 2f7329220f1a6acb8cd8a1e06d8a79820160441e Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 25 Jun 2026 13:28:00 +0200 Subject: [PATCH 08/11] guard splits --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 95fcce75e2fe..078284cccf8a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -319,7 +319,7 @@ function IOURequestStepConfirmation({ } setMoneyRequestParticipants(activeTransactionID, participantsList); const firstParticipant = participantsList.at(0); - if (firstParticipant?.reportID) { + if (firstParticipant?.reportID && iouType !== CONST.IOU.TYPE.SPLIT) { setTransactionReport(activeTransactionID, {reportID: firstParticipant.reportID}, true); } } From cc96f1cdce6dba7f28ffd3cd06ab70dfc56ed1f6 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 25 Jun 2026 14:21:20 +0200 Subject: [PATCH 09/11] resolve earlyReportID earlier --- src/hooks/useResetIOUType.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useResetIOUType.ts b/src/hooks/useResetIOUType.ts index 5b4842ae0de7..5acf93e33854 100644 --- a/src/hooks/useResetIOUType.ts +++ b/src/hooks/useResetIOUType.ts @@ -110,6 +110,13 @@ function useResetIOUType({ const isFromGlobalCreate = !report?.reportID; + // Resolve early so we can decide whether to seed participants below. + const earlyReportID = resolveEarlyReportID(isFromGlobalCreate, defaultParticipants); + const isSelfDMDefault = earlyReportID === CONST.REPORT.UNREPORTED_REPORT_ID; + + // Skip seeding self-DM participants here. The confirmation's auto-assign + // useEffect is the only place that can both set the reportID and switch + // iouType to TRACK; seeding them early would short-circuit that effect. initMoneyRequest({ reportID, policy, @@ -126,15 +133,14 @@ function useResetIOUType({ currentUserPersonalDetails, hasOnlyPersonalPolicies: hasOnlyPersonalPolicies ?? true, draftTransactionIDs, - defaultParticipants, + defaultParticipants: isSelfDMDefault ? undefined : defaultParticipants, }); // Set the transaction reportID early for global-create flows with resolved default // participants. This ensures destinationReportID is defined from the confirmation's // first render, preventing the pre-insert useEffect from seeing an undefined-to-value // transition that would tear down and fail to re-fire the pre-insert. - const earlyReportID = resolveEarlyReportID(isFromGlobalCreate, defaultParticipants); - if (earlyReportID) { + if (earlyReportID && !isSelfDMDefault) { setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: earlyReportID}, true); } From 15318823b42dab7ec8348117f7705b11f47dc94f Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 25 Jun 2026 14:43:09 +0200 Subject: [PATCH 10/11] fix: reportID fallback --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 078284cccf8a..b08e5ad8998a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -319,15 +319,15 @@ function IOURequestStepConfirmation({ } setMoneyRequestParticipants(activeTransactionID, participantsList); const firstParticipant = participantsList.at(0); - if (firstParticipant?.reportID && iouType !== CONST.IOU.TYPE.SPLIT) { - setTransactionReport(activeTransactionID, {reportID: firstParticipant.reportID}, true); + if (iouType !== CONST.IOU.TYPE.SPLIT) { + setTransactionReport(activeTransactionID, {reportID: firstParticipant?.reportID ?? reportID}, true); } } if (participantsList.length > 0) { closeParticipantPicker(); } }, - [activeTransactionID, closeParticipantPicker, currentUserPersonalDetails.accountID, navigation, selfDMReport, iouType], + [activeTransactionID, closeParticipantPicker, currentUserPersonalDetails.accountID, navigation, selfDMReport, iouType, reportID], ); useEffect(() => { From 90dd130b673316d2e0ff0acdc1958fa37151f214 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 25 Jun 2026 15:14:56 +0200 Subject: [PATCH 11/11] fix: self-dms on search --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index b08e5ad8998a..fc82a8aa604a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -468,8 +468,10 @@ function IOURequestStepConfirmation({ // Only eligible when search pre-insert didn't win, and the flow ends at a report (not Search). // When Search is the topmost fullscreen and there's no report context (e.g. QAB from Spend tab), // pre-inserting a report is wrong - the user should stay on Search after submission. - // Global-create TRACK targets self-DM (a report), so it's also eligible for report pre-insert. - const canUseReportPreInsert = !shouldPreInsertSearch && (isReportTopmostSplitNavigator() || isCreatingTrackExpense || (!isFromGlobalCreate && !isSearchTopmostFullScreenRoute())); + // Global-create TRACK targets self-DM (a report), so it's also eligible for report + // pre-insert, but only when Search is NOT topmost. When on Search/Spend the user + // should stay there after submission (navigateAfterExpenseCreate routes to Expenses search). + const canUseReportPreInsert = !shouldPreInsertSearch && (isReportTopmostSplitNavigator() || (!isSearchTopmostFullScreenRoute() && (isCreatingTrackExpense || !isFromGlobalCreate))); // RHP has its own dismiss handler; pre-inserting under it would break the stack. const isOutsideRHP = !isReportOpenInRHP(navigationRef.getRootState());