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
19 changes: 18 additions & 1 deletion src/hooks/useResetIOUType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ 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';
import type {IOURequestType, IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -108,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,
Expand All @@ -124,9 +133,17 @@ 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.
if (earlyReportID && !isSelfDMDefault) {
setTransactionReport(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, {reportID: earlyReportID}, true);
Comment thread
JakubKorytko marked this conversation as resolved.
}

// 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);
Expand Down
17 changes: 17 additions & 0 deletions src/libs/IOUUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Comment thread
JakubKorytko marked this conversation as resolved.
export {
calculateAmount,
calculateSplitAmountFromPercentage,
Expand All @@ -542,4 +558,5 @@ export {
isParticipantP2P,
resolveOptimisticChatReportID,
resolveReportForMoneyRequest,
resolveEarlyReportID,
};
47 changes: 34 additions & 13 deletions src/pages/iou/request/step/IOURequestStepConfirmation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -318,12 +318,16 @@ function IOURequestStepConfirmation({
navigation.setParams({iouType: CONST.IOU.TYPE.CREATE});
}
setMoneyRequestParticipants(activeTransactionID, participantsList);
const firstParticipant = participantsList.at(0);
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(() => {
Expand All @@ -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]);

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -462,7 +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.
const canUseReportPreInsert = !shouldPreInsertSearch && (isReportTopmostSplitNavigator() || (!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());
Expand Down Expand Up @@ -495,19 +504,31 @@ 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. 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
// 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]);

Expand Down
46 changes: 46 additions & 0 deletions tests/unit/useResetIOUTypeReportIDTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {resolveEarlyReportID} from '@libs/IOUUtils';
import CONST from '@src/CONST';
import type {Participant} from '@src/types/onyx/IOU';

describe('resolveEarlyReportID', () => {
it('returns workspace chat reportID for workspace participants', () => {
const participants: Participant[] = [{accountID: 0, reportID: 'workspace-chat-123', isPolicyExpenseChat: true, selected: true}];

expect(resolveEarlyReportID(true, participants)).toBe('workspace-chat-123');
});

it('returns UNREPORTED_REPORT_ID for selfDM participants', () => {
const participants: Participant[] = [{accountID: 0, reportID: 'self-dm-456', isSelfDM: true, selected: true}];

expect(resolveEarlyReportID(true, participants)).toBe(CONST.REPORT.UNREPORTED_REPORT_ID);
});

it('returns undefined when not from global create', () => {
const participants: Participant[] = [{accountID: 0, reportID: 'some-report', selected: true}];

expect(resolveEarlyReportID(false, participants)).toBeUndefined();
});

it('returns undefined when participants array is empty', () => {
expect(resolveEarlyReportID(true, [])).toBeUndefined();
});

it('returns undefined when participants is undefined', () => {
expect(resolveEarlyReportID(true, undefined)).toBeUndefined();
});

it('returns undefined when first participant has no reportID and is not selfDM', () => {
const participants: Participant[] = [{accountID: 123, selected: true}];

expect(resolveEarlyReportID(true, participants)).toBeUndefined();
});

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(resolveEarlyReportID(true, participants)).toBe('first-report');
});
});
Loading