Skip to content
Closed
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
13 changes: 11 additions & 2 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1312,13 +1312,22 @@ const ROUTES = {
},
MONEY_REQUEST_STEP_MERCHANT: {
route: ':action/:iouType/merchant/:transactionID/:reportID/:reportActionID?',
getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', reportActionID?: string) => {
getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '', reportActionID?: string, backToReport?: string) => {
if (!transactionID || !reportID) {
Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_STEP_MERCHANT route');
}

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.

❌ CONSISTENCY-2 (docs)

The route generation logic constructs query parameters and paths manually using string concatenation. While functional, this approach is error-prone and makes it harder to understand the relationship between reportActionID and backToReport parameters.

Suggested fix:
Add comments to clarify the parameter precedence and construction logic:

// Build optional route segments:
// 1. reportActionID is added as path segment (/:reportActionID)
// 2. backToReport is added as query parameter (?backToReport=...)
let optionalRoutePart = "";

if (reportActionID) {
    optionalRoutePart += `/${reportActionID}`;
}
if (backToReport) {
    optionalRoutePart += `?backToReport=${backToReport}`;
}

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

let optionalRoutePart = '';

if (reportActionID) {
optionalRoutePart += `/${reportActionID}`;
}
if (backToReport) {
optionalRoutePart += `?backToReport=${backToReport}`;
}

// eslint-disable-next-line no-restricted-syntax -- Legacy route generation
return getUrlWithBackToParam(`${action as string}/${iouType as string}/merchant/${transactionID}/${reportID}${reportActionID ? `/${reportActionID}` : ''}`, backTo);
return getUrlWithBackToParam(`${action as string}/${iouType as string}/merchant/${transactionID}/${reportID}${optionalRoutePart}`, backTo);
},
},
MONEY_REQUEST_STEP_PARTICIPANTS: {
Expand Down
34 changes: 32 additions & 2 deletions src/libs/IOUUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {OnyxInputOrEntry, PersonalDetails, Policy, Report} from '@src/types/onyx';
import type {Attendee, Participant} from '@src/types/onyx/IOU';
import type Transaction from '@src/types/onyx/Transaction';
import SafeString from '@src/utils/SafeString';
import type {IOURequestType} from './actions/IOU';
import {getCurrencyUnit} from './CurrencyUtils';
import Navigation from './Navigation/Navigation';
import Performance from './Performance';
import {isPaidGroupPolicy} from './PolicyUtils';
import {getReportTransactions} from './ReportUtils';
import {getCurrency, getTagArrayFromName} from './TransactionUtils';
import {getReportTransactions, isExpenseReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from './ReportUtils';
import {getCurrency, getTagArrayFromName, isMerchantMissing, isScanRequest} from './TransactionUtils';

function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: IOUType, transactionID: string, reportID: string, iouAction?: IOUAction): void {
if (iouAction === CONST.IOU.ACTION.CATEGORIZE || iouAction === CONST.IOU.ACTION.SUBMIT || iouAction === CONST.IOU.ACTION.SHARE) {
Expand Down Expand Up @@ -324,6 +325,34 @@ function formatCurrentUserToAttendee(currentUser?: PersonalDetails, reportID?: s
return [initialAttendee];
}

/**
* Checks if merchant is required and missing for a transaction.
* Merchant is required for policy expense chats, expense requests, or when any participant is a policy expense chat.
* For scan requests, merchant is not required unless it's a split bill being edited.
*
* @param transaction - The transaction to check
* @param report - The report associated with the transaction
* @param isEditingSplitBill - Whether this is editing a split bill
* @returns true if merchant is required and missing, false otherwise
*/
function shouldRequireMerchant(transaction: OnyxInputOrEntry<Transaction> | undefined, report: OnyxInputOrEntry<Report> | undefined, isEditingSplitBill = false): boolean {
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.

❌ CONSISTENCY-6 (docs)

The shouldRequireMerchant function accesses properties from potentially undefined objects (transaction?.participants, report) without comprehensive null checks. While the function returns false for undefined transactions, it could still throw errors if transaction.participants is malformed.

Suggested fix:
Add defensive checks to handle edge cases:

function shouldRequireMerchant(transaction: OnyxInputOrEntry<Transaction> | undefined, report: OnyxInputOrEntry<Report> | undefined, isEditingSplitBill = false): boolean {
    if (\!transaction) {
        return false;
    }

    if (\!isMerchantMissing(transaction)) {
        return false;
    }

    // For scan requests, merchant is not required unless it's a split bill being edited
    if (isScanRequest(transaction) && \!isEditingSplitBill) {
        return false;
    }

    // Add defensive check for participants array
    const hasParticipantsWithPolicyExpenseChat = Array.isArray(transaction?.participants) && 
        transaction.participants.some((participant) => \!\!participant?.isPolicyExpenseChat);

    // Check if merchant is required based on report type and participants
    return \!\!(isPolicyExpenseChatUtils(report) || isExpenseReport(report) || hasParticipantsWithPolicyExpenseChat);
}

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

if (!transaction) {
return false;
}

if (!isMerchantMissing(transaction)) {
return false;
}

// For scan requests, merchant is not required unless it's a split bill being edited
if (isScanRequest(transaction) && !isEditingSplitBill) {
return false;
}

// Check if merchant is required based on report type and participants
return !!(isPolicyExpenseChatUtils(report) || isExpenseReport(report) || transaction?.participants?.some((participant) => !!participant.isPolicyExpenseChat));
}

function navigateToConfirmationPage(
iouType: IOUType,
transactionID: string,
Expand Down Expand Up @@ -392,6 +421,7 @@ export {
formatCurrentUserToAttendee,
navigateToParticipantPage,
shouldShowReceiptEmptyState,
shouldRequireMerchant,
navigateToConfirmationPage,
calculateDefaultReimbursable,
};
1 change: 1 addition & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1846,6 +1846,7 @@ type MoneyRequestNavigatorParamList = {
// eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md
backTo: Routes;
reportActionID?: string;
backToReport?: string;
};
[SCREENS.IOU_SEND.ENABLE_PAYMENTS]: undefined;
[SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: undefined;
Expand Down
18 changes: 16 additions & 2 deletions src/pages/iou/request/step/IOURequestStepAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep';
import {setTransactionReport} from '@libs/actions/Transaction';
import {convertToBackendAmount} from '@libs/CurrencyUtils';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {calculateDefaultReimbursable, isMovingTransactionFromTrackExpense, navigateToConfirmationPage, navigateToParticipantPage} from '@libs/IOUUtils';
import {calculateDefaultReimbursable, isMovingTransactionFromTrackExpense, navigateToConfirmationPage, navigateToParticipantPage, shouldRequireMerchant} from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils';
import {getPolicyExpenseChat, getReportOrDraftReport, getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from '@libs/ReportUtils';
Expand Down Expand Up @@ -307,8 +307,14 @@ function IOURequestStepAmount({
setSplitShares(transaction, amountInSmallestCurrencyUnits, selectedCurrency || CONST.CURRENCY.USD, participantAccountIDs);
}
setMoneyRequestParticipantsFromReport(transactionID, report, currentUserPersonalDetails.accountID).then(() => {
// If merchant is required and missing, navigate to merchant step first
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-2 (docs)

The merchant requirement check at line 310 is performed AFTER calling setMoneyRequestParticipantsFromReport which is an expensive async operation. If merchant is required, the user immediately navigates away, making the confirmation page setup work unnecessary.

Suggested fix:
Check merchant requirement before the async operation:

// Check merchant requirement first to avoid unnecessary async work
if (shouldRequireMerchant(transaction, report, isEditingSplitBill)) {
    Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, undefined, reportActionID, backToReport));
    return;
}

setMoneyRequestParticipantsFromReport(transactionID, report, currentUserPersonalDetails.accountID).then(() => {
    navigateToConfirmationPage(iouType, transactionID, reportID, backToReport);
});

This avoids the async participant setup when it's not needed for the next screen.


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

if (shouldRequireMerchant(transaction, report, isEditingSplitBill)) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, undefined, reportActionID, backToReport));
return;
}
navigateToConfirmationPage(iouType, transactionID, reportID, backToReport);
});

return;
}

Expand All @@ -324,7 +330,11 @@ function IOURequestStepAmount({
const resetToDefaultWorkspace = () => {
setTransactionReport(transactionID, {reportID: transactionReportID}, true);
setMoneyRequestParticipantsFromReport(transactionID, targetReport, currentUserPersonalDetails.accountID).then(() => {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, transactionID, targetReport?.reportID));
if (transactionReportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
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-2 (docs)

The merchant requirement check at line 333 could be performed BEFORE calling setMoneyRequestParticipantsFromReport to avoid unnecessary async work when navigating to the merchant step.

Suggested fix:
Restructure the logic to check merchant requirement first:

const resetToDefaultWorkspace = () => {
    setTransactionReport(transactionID, {reportID: transactionReportID}, true);
    
    // Check if we need merchant step before expensive participant setup
    if (transactionReportID !== CONST.REPORT.UNREPORTED_REPORT_ID) {
        Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, targetReport?.reportID, undefined, reportActionID));
        return;
    }
    
    setMoneyRequestParticipantsFromReport(transactionID, targetReport, currentUserPersonalDetails.accountID).then(() => {
        Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, transactionID, targetReport?.reportID));
    });
};

This avoids the async participant operation when immediately navigating to the merchant step.


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

Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, iouTypeTrackOrSubmit, transactionID, targetReport?.reportID));
return;
}
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(action, CONST.IOU.TYPE.SUBMIT, transactionID, targetReport?.reportID, undefined, reportActionID));
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Gate default-workspace merchant redirect on requirement

This branch now always sends users to the merchant step whenever transactionReportID is not UNREPORTED, even though the transaction can already have a valid merchant (for example after returning from confirmation and being reset from a P2P selection due to a negative amount). In that case we should go straight back to confirmation, but this unconditional redirect reintroduces a redundant merchant step and inconsistent back navigation. Reuse shouldRequireMerchant(...) here before deciding the route.

Useful? React with 👍 / 👎.

});
};

Expand All @@ -346,6 +356,10 @@ function IOURequestStepAmount({
const chatReportID = selectedReport?.chatReportID ?? selectedReport?.reportID;

Navigation.setNavigationActionToMicrotaskQueue(() => {
if (shouldRequireMerchant(transaction, selectedReport, isEditingSplitBill)) {
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-2 (docs)

The merchant requirement check at line 359 is performed AFTER calling setNavigationActionToMicrotaskQueue and setting up the confirmation route. If merchant is required, this setup work is wasted since navigation immediately changes direction.

Suggested fix:
Check merchant requirement before queuing navigation work:

if (shouldRequireMerchant(transaction, selectedReport, isEditingSplitBill)) {
    Navigation.setNavigationActionToMicrotaskQueue(() => {
        Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.CREATE, navigationIOUType, transactionID, chatReportID, undefined, reportActionID));
    });
    return;
}

Navigation.setNavigationActionToMicrotaskQueue(() => {
    Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, navigationIOUType, transactionID, chatReportID));
});

This avoids setting up navigation to the confirmation page when the merchant page is the actual target.


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

Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(CONST.IOU.ACTION.CREATE, navigationIOUType, transactionID, chatReportID, undefined, reportActionID));
return;
}
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(CONST.IOU.ACTION.CREATE, navigationIOUType, transactionID, chatReportID));
});
} else {
Expand Down
27 changes: 23 additions & 4 deletions src/pages/iou/request/step/IOURequestStepMerchant.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
Expand All @@ -21,6 +22,7 @@ import {setMoneyRequestMerchant, updateMoneyRequestMerchant} from '@userActions/
import {setDraftSplitTransaction} from '@userActions/IOU/Split';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import INPUT_IDS from '@src/types/form/MoneyRequestMerchantForm';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
Expand All @@ -36,7 +38,7 @@ type IOURequestStepMerchantProps = WithWritableReportOrNotFoundProps<typeof SCRE

function IOURequestStepMerchant({
route: {
params: {transactionID, reportID, backTo, action, iouType, reportActionID},
params: {transactionID, reportID, backTo, action, iouType, reportActionID, backToReport},
},
transaction,
report,
Expand Down Expand Up @@ -69,19 +71,35 @@ function IOURequestStepMerchant({
const {isBetaEnabled} = usePermissions();
const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT);

const isMerchantRequired = isPolicyExpenseChat(report) || isExpenseRequest(report) || transaction?.participants?.some((participant) => !!participant.isPolicyExpenseChat);
const isMerchantRequired = useMemo(() => {
if (isEditing) {
return isPolicyExpenseChat(report) || isExpenseRequest(report);
}
return transaction?.participants?.some((participant) => !!participant.isPolicyExpenseChat);
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-6 (docs)

The useFocusEffect hook is resetting component state (isSaved and currentMerchant) based on props changing. This state can be derived directly from props instead of using an effect.

Suggested fix:
Remove the useFocusEffect and derive the state directly. The currentMerchant state can be initialized directly from initialMerchant without needing to reset it in an effect:

// Remove useFocusEffect - state will naturally reset when component remounts
// The currentMerchant state is already initialized from initialMerchant
const [currentMerchant, setCurrentMerchant] = useState(initialMerchant);

If you need to respond to prop changes, consider using the key prop pattern (see PERF-7) or compute values during render.


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

}, [isEditing, report, transaction?.participants]);

const navigateBack = useCallback(() => {
Navigation.goBack(backTo);
}, [backTo]);
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-6 (docs)

The navigation logic inside useEffect depends on multiple values (backTo, isEditing, backToReport) to determine where to navigate. This could be computed during the navigation action itself rather than in a useEffect that triggers after state changes.

Suggested fix:
Consider computing the navigation target at the point where setIsSaved(true) is called in the updateMerchant function, rather than relying on a useEffect to react to the isSaved state change:

const updateMerchant = (value: FormOnyxValues<typeof ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM>) => {
    const newMerchant = value.moneyRequestMerchant?.trim();
    
    // ... existing validation logic ...
    
    // Compute navigation target directly
    const shouldNavigateToConfirmation = !isEditing && !backTo;
    if (shouldNavigateToConfirmation) {
        Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID, backToReport, undefined, Navigation.getActiveRoute()));
    } else {
        Navigation.goBack(backTo);
    }
};

This eliminates the need for the isSaved state and the corresponding useEffect.


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


useFocusEffect(
useCallback(() => {
setIsSaved(false);
setCurrentMerchant(initialMerchant);
}, [initialMerchant]),
);

useEffect(() => {
if (!isSaved || !shouldNavigateAfterSaveRef.current) {
return;
}
shouldNavigateAfterSaveRef.current = false;
if (!isEditing && !backTo) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID, backToReport, undefined, Navigation.getActiveRoute()));
return;
}
navigateBack();
}, [isSaved, navigateBack]);
}, [isSaved, navigateBack, action, iouType, transactionID, reportID, backTo, isEditing, backToReport]);

const validate = useCallback(
(value: FormOnyxValues<typeof ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM>) => {
Expand Down Expand Up @@ -165,6 +183,7 @@ function IOURequestStepMerchant({
inputID={INPUT_IDS.MONEY_REQUEST_MERCHANT}
name={INPUT_IDS.MONEY_REQUEST_MERCHANT}
defaultValue={initialMerchant}
value={currentMerchant}
onValueChange={updateMerchantRef}
label={translate('common.merchant')}
accessibilityLabel={translate('common.merchant')}
Expand Down
37 changes: 30 additions & 7 deletions src/pages/iou/request/step/IOURequestStepParticipants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {isPaidGroupPolicy} from '@libs/PolicyUtils';
import {findSelfDMReportID, generateReportID, isInvoiceRoomWithID} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
import {endSpan} from '@libs/telemetry/activeSpans';
import {getRequestType, hasRoute, isCorporateCardTransaction, isDistanceRequest, isPerDiemRequest} from '@libs/TransactionUtils';
import {getRequestType, hasRoute, isCorporateCardTransaction, isDistanceRequest, isMerchantMissing, isPerDiemRequest} from '@libs/TransactionUtils';
import MoneyRequestParticipantsSelector from '@pages/iou/request/MoneyRequestParticipantsSelector';
import {
navigateToStartStepIfScanFileCannotBeRead,
Expand All @@ -38,6 +38,7 @@ import {createDraftWorkspace} from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Route} from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {Policy} from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
Expand Down Expand Up @@ -88,6 +89,7 @@ function IOURequestStepParticipants({

// We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant
const selectedReportID = useRef<string>(participants?.length === 1 ? (participants.at(0)?.reportID ?? reportID) : reportID);
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.

❌ CONSISTENCY-4 (docs)

The selectedParticipants ref is created but only used once at line 341 to read the first participant. This ref is redundant since the same value can be accessed directly from the participants variable or the function parameter val in addParticipant.

Suggested fix:
Remove the selectedParticipants ref and use the existing participants prop or the val parameter directly:

// Remove line 91:
// const selectedParticipants = useRef<Participant[]>(participants);

// Remove line 226:
// selectedParticipants.current = val;

// At line 341, use val or participants directly:
const firstParticipant = participants?.at(0); // or use _participants parameter if available

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

const selectedParticipants = useRef<Participant[]>(participants);
const numberOfParticipants = useRef(participants?.length ?? 0);
const iouRequestType = getRequestType(initialTransaction);
const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT;
Expand Down Expand Up @@ -221,6 +223,7 @@ function IOURequestStepParticipants({
(val: Participant[]) => {
HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS);

selectedParticipants.current = val;
const firstParticipant = val.at(0);

if (firstParticipant?.isSelfDM && !isSplitRequest) {
Expand Down Expand Up @@ -335,6 +338,12 @@ function IOURequestStepParticipants({
return;
}

const firstParticipant = selectedParticipants.current?.at(0);
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-2 (docs)

The merchant requirement check is performed after several expensive operations including setting transaction report, split shares, and other transaction manipulations. This check could be moved earlier to avoid unnecessary work.

Suggested fix:
Move the merchant requirement check before the expensive transaction operations:

const goToNextStep = useCallback(
    (_value?: string, _participants?: Participant[]) => {
        const isCategorizing = action === CONST.IOU.ACTION.CATEGORIZE;
        const isShareAction = action === CONST.IOU.ACTION.SHARE;

        // Check merchant requirement early
        const firstParticipant = _participants?.at(0) || participants?.at(0);
        const isMerchantRequired =
            \!\!firstParticipant?.isPolicyExpenseChat &&
            isMerchantMissing(initialTransaction) &&
            (iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || (isMovingTransactionFromTrackExpense && iouRequestType === CONST.IOU.REQUEST_TYPE.TIME));

        // ... rest of the logic
    },
    // ...
);

This avoids performing split share calculations and other operations when the user will immediately navigate to the merchant page.


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

const isMerchantRequired =
!!firstParticipant?.isPolicyExpenseChat &&
isMerchantMissing(initialTransaction) &&
(iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || (isMovingTransactionFromTrackExpense && iouRequestType === CONST.IOU.REQUEST_TYPE.TIME));

const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(
action,
iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.SUBMIT : iouType,
Expand All @@ -345,21 +354,34 @@ function IOURequestStepParticipants({
action === CONST.IOU.ACTION.SHARE ? Navigation.getActiveRoute() : undefined,
);

const route = isCategorizing
? ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, initialTransactionID, selectedReportID.current || reportID, iouConfirmationPageRoute)
: iouConfirmationPageRoute;
let route: Route = iouConfirmationPageRoute;

if (isCategorizing) {
route = ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, initialTransactionID, selectedReportID.current || reportID, iouConfirmationPageRoute);
} else if (isMerchantRequired) {
route = ROUTES.MONEY_REQUEST_STEP_MERCHANT.getRoute(
action,
iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.SUBMIT : iouType,
initialTransactionID,
newReportID,
);
}

Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE_APPROVE);
waitForKeyboardDismiss(() => {
// If the backTo parameter is set, we should navigate back to the confirmation screen that is already on the stack.
// We wrap navigation in setNavigationActionToMicrotaskQueue so that data loading in Onyx and navigation do not occur simultaneously, which resets the amount to 0.
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.

❌ CONSISTENCY-6 (docs)

The navigation logic has complex conditional branching with multiple paths (backTo with/without isMerchantRequired, merchant step with forceReplace, etc.) but lacks error handling or validation for edge cases. If the navigation fails or produces unexpected states, there's no fallback.

Suggested fix:
Add validation and comments to clarify the navigation logic:

// Navigation logic:
// 1. If merchant is required and backTo is set: go back first, then replace with merchant page
// 2. If merchant is required without backTo: navigate to merchant page with forceReplace
// 3. If merchant not required and backTo is set: go back to confirmation
// 4. Otherwise: navigate normally to confirmation or category page

if (backTo && !isMerchantRequired) {
    // We don't want to compare params because we just changed the participants.
    Navigation.goBack(route, {compareParams: false});
} else {
    // If the merchant step is required and the backTo parameter is set, we need to go back to the confirmation screen first
    if (isMerchantRequired && backTo) {
        Navigation.goBack();
    }
    Navigation.navigate(route, {forceReplace: isMerchantRequired && !!backTo});
}

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

// More information can be found here: https://github.com/Expensify/App/issues/73728
Navigation.setNavigationActionToMicrotaskQueue(() => {
if (backTo) {
if (backTo && !isMerchantRequired) {
// We don't want to compare params because we just changed the participants.
Navigation.goBack(route, {compareParams: false});
} else {
Navigation.navigate(route);
// If the merchant step is required and the backTo parameter is set, we need to go back the the confirmation screen first and then navigate to the merchant page with forceReplace to remove this screen from the stack
if (isMerchantRequired && backTo) {
Navigation.goBack();
}
Navigation.navigate(route, {forceReplace: isMerchantRequired && !!backTo});
}
});
});
Expand All @@ -369,14 +391,15 @@ function IOURequestStepParticipants({
participants,
iouType,
initialTransaction,
iouRequestType,
initialTransactionID,
reportID,
waitForKeyboardDismiss,
transactions,
isMovingTransactionFromTrackExpense,
allPolicies,
policyForMovingExpenses,
introSelected,
reportID,
backTo,
selfDMReportID,
],
Expand Down
Loading