From 8ed948281c6a52c2ea9a1a26858b0661bbd7dc93 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 1 Apr 2026 12:49:20 +0200 Subject: [PATCH 1/8] extract renderless components from IOURequestStepConfirmation and fix collection subs --- .../step/IOURequestStepConfirmation.tsx | 189 +++++------------- .../confirmation/CategoryDefaultsSetter.tsx | 71 +++++++ .../confirmation/DraftWorkspaceOpener.tsx | 45 +++++ .../confirmation/ExpenseDefaultsSetter.tsx | 46 +++++ .../confirmation/MoneyRequestInitializer.tsx | 44 ++++ .../confirmation/TelemetrySpanManager.tsx | 50 +++++ 6 files changed, 308 insertions(+), 137 deletions(-) create mode 100644 src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx create mode 100644 src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx create mode 100644 src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx create mode 100644 src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx create mode 100644 src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index a63ded546c1f..496b4c313b8b 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -56,7 +56,6 @@ import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction' import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {getParticipantsOption, getReportOption} from '@libs/OptionsListUtils'; -import {isPaidGroupPolicy} from '@libs/PolicyUtils'; import { findSelfDMReportID, generateReportID, @@ -68,7 +67,7 @@ import { isSelectedManagerMcTest, } from '@libs/ReportUtils'; import stitchOdometerImages from '@libs/stitchOdometerImages'; -import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; @@ -92,11 +91,9 @@ import { getIOURequestPolicyID, requestMoney as requestMoneyIOUActions, setMoneyRequestBillable, - setMoneyRequestCategory, setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt, setMoneyRequestReimbursable, - startMoneyRequest, trackExpense as trackExpenseIOUActions, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; @@ -104,7 +101,6 @@ import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpe import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; -import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy'; import {removeDraftTransaction, removeDraftTransactionsByIDs, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -118,6 +114,11 @@ import type {Receipt} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import CategoryDefaultsSetter from './confirmation/CategoryDefaultsSetter'; +import DraftWorkspaceOpener from './confirmation/DraftWorkspaceOpener'; +import ExpenseDefaultsSetter from './confirmation/ExpenseDefaultsSetter'; +import MoneyRequestInitializer from './confirmation/MoneyRequestInitializer'; +import TelemetrySpanManager from './confirmation/TelemetrySpanManager'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -193,7 +194,6 @@ function IOURequestStepConfirmation({ const draftPolicyID = getIOURequestPolicyID(initialTransaction, reportDraft); const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${draftPolicyID}`); const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${realPolicyID}`); - const [reportDrafts] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT); const [gpsDraftDetails] = useOnyx(ONYXKEYS.GPS_DRAFT_DETAILS); const [betas] = useOnyx(ONYXKEYS.BETAS); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -255,6 +255,8 @@ function IOURequestStepConfirmation({ const reportAttributesDerived = useReportAttributes(); const [recentlyUsedDestinations] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS}${policyID}`); const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); + const transactionViolationsRef = useRef(transactionViolations); + transactionViolationsRef.current = transactionViolations; const hasViolations = hasViolationsReportUtils(report?.reportID, transactionViolations, currentUserPersonalDetails.accountID, currentUserPersonalDetails.login ?? ''); const policyCategories = useMemo(() => { @@ -350,9 +352,11 @@ function IOURequestStepConfirmation({ const privateIsArchived = privateIsArchivedMap[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${participant.reportID}`]; return participant.accountID ? getParticipantsOption(participant, personalDetails) - : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived, reportDrafts); + : getReportOption(participant, privateIsArchived, policy, personalDetails, conciergeReportID, reportAttributesDerived); }) ?? [], - [transaction?.participants, iouType, personalDetails, reportAttributesDerived, reportDrafts, privateIsArchivedMap, policy, conciergeReportID], + // getReportOrDraftReport (called inside getReportOption) falls back to its module-level allReportsDraft + // connection, so we don't need to subscribe to COLLECTION.REPORT_DRAFT here. + [transaction?.participants, iouType, personalDetails, reportAttributesDerived, privateIsArchivedMap, policy, conciergeReportID], ); const participantsPolicyTags = useParticipantsPolicyTags(participants ?? []); const isPolicyExpenseChat = useMemo(() => participants?.some((participant) => participant.isPolicyExpenseChat), [participants]); @@ -363,60 +367,9 @@ function IOURequestStepConfirmation({ useFetchRoute(transaction, transaction?.comment?.waypoints, action, shouldUseTransactionDraft(action, iouType) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT); - useEffect(() => { - endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); - endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT); - - // Grab parent ref before ending it — children need it for parent_span_id linking - const parentSpan = getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); - - startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, { - name: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, - op: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, - parentSpan, - attributes: {[CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType}, - }); - startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, { - name: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, - op: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, - parentSpan, - }); - - // End parent AFTER children are created — Sentry preserves parent_span_id regardless - endSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); - - return () => { - cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY); - cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on mount/unmount - }, []); - - useEffect(() => { - if (!isCreatingTrackExpense || policyID === undefined) { - return; - } - - openDraftWorkspaceRequest(policyID); - }, [isCreatingTrackExpense, policy?.pendingAction, policyID]); - - const policyExpenseChatPolicyID = useMemo(() => { - return participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID; - }, [participants]); + const policyExpenseChatPolicyID = participants?.find((participant) => participant.isPolicyExpenseChat)?.policyID; - const senderPolicyID = useMemo(() => { - return participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID; - }, [participants]); - - useEffect(() => { - if (policyExpenseChatPolicyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - openDraftWorkspaceRequest(policyExpenseChatPolicyID); - return; - } - if (senderPolicyID && policy?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - openDraftWorkspaceRequest(senderPolicyID); - } - }, [isOffline, policy?.pendingAction, policyExpenseChatPolicyID, senderPolicyID]); + const senderPolicyID = participants?.find((participant) => !!participant && 'isSender' in participant && participant.isSender)?.policyID; const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; @@ -478,75 +431,6 @@ function IOURequestStepConfirmation({ }; }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType]); - const defaultBillable = !!policy?.defaultBillable; - useEffect(() => { - if (isMovingTransactionFromTrackExpense) { - return; - } - for (const transactionID of transactionIDs) { - setMoneyRequestBillable(transactionID, defaultBillable); - } - }, [transactionIDs, defaultBillable, isMovingTransactionFromTrackExpense]); - - useEffect(() => { - if (isMovingTransactionFromTrackExpense) { - return; - } - const defaultReimbursable = (isPolicyExpenseChat && isPaidGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; - for (const transactionID of transactionIDs) { - setMoneyRequestReimbursable(transactionID, defaultReimbursable); - } - }, [transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense]); - - useEffect(() => { - // Exit early if the transaction is still loading - if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) { - return; - } - - startMoneyRequest( - iouType ?? CONST.IOU.TYPE.CREATE, - // When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used - // for all of the routes in the creation flow. - reportID ?? generateReportID(), - draftTransactionIDs, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again - }, [isLoadingTransaction]); - - useEffect(() => { - for (const item of transactions) { - if (!item.category) { - // If the expense had his category cleared due to unsaved changes (i.e. changing to recipient to one that does not have category) - // then we should reset the category to it's last saved value - const existingCategory = existingTransaction?.category; - if (existingCategory) { - const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled; - if (isExistingCategoryEnabled) { - setMoneyRequestCategory(item.transactionID, existingCategory, policy); - } - } - continue; - } - } - // We don't want to clear out category every time the transactions change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policy?.id, policyCategories, transactions.length]); - - const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); - const defaultCategory = policyDistance?.defaultCategory ?? ''; - - useEffect(() => { - for (const item of transactions) { - if (!isDistanceRequest || !!item?.category) { - continue; - } - setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense); - } - // Prevent resetting to default when unselect category - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [transactionIDs, requestType, defaultCategory, policy?.id]); - const navigateBack = useCallback(() => { if (backTo) { Navigation.goBack(backTo); @@ -669,6 +553,7 @@ function IOURequestStepConfirmation({ removeDraftTransactionsByIDs(draftTransactionIDs, true); navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID); }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants]); const requestMoney = useCallback( @@ -788,7 +673,7 @@ function IOURequestStepConfirmation({ isASAPSubmitBetaEnabled, currentUserAccountIDParam: currentUserPersonalDetails.accountID, currentUserEmailParam: currentUserPersonalDetails.email ?? '', - transactionViolations, + transactionViolations: transactionViolationsRef.current, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], quickAction, existingTransactionDraft, @@ -823,7 +708,6 @@ function IOURequestStepConfirmation({ shouldGenerateTransactionThreadReport, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, policyRecentlyUsedCurrencies, quickAction, isSelfTourViewed, @@ -1085,7 +969,7 @@ function IOURequestStepConfirmation({ }, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], personalDetails, @@ -1116,7 +1000,6 @@ function IOURequestStepConfirmation({ gpsDraftDetails, backToReport, isASAPSubmitBetaEnabled, - transactionViolations, quickAction, policyRecentlyUsedCurrencies, personalDetails, @@ -1264,7 +1147,7 @@ function IOURequestStepConfirmation({ policyRecentlyUsedCategories, policyRecentlyUsedTags, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], betas, @@ -1299,7 +1182,7 @@ function IOURequestStepConfirmation({ policyRecentlyUsedCategories, policyRecentlyUsedTags, isASAPSubmitBetaEnabled, - transactionViolations, + transactionViolations: transactionViolationsRef.current, quickAction, policyRecentlyUsedCurrencies: policyRecentlyUsedCurrencies ?? [], betas, @@ -1419,7 +1302,6 @@ function IOURequestStepConfirmation({ policyRecentlyUsedTags, quickAction, isASAPSubmitBetaEnabled, - transactionViolations, existingInvoiceReport, policy, policyTags, @@ -1618,6 +1500,39 @@ function IOURequestStepConfirmation({ shouldAvoidScrollOnVirtualViewport={!isMobileSafari()} testID="IOURequestStepConfirmation" > + + + + + ; + policyCategories: OnyxEntry; + policy: OnyxEntry; + isDistanceRequest: boolean; + requestType: string | undefined; + isMovingTransactionFromTrackExpense: boolean; +}; + +/** + * Renderless component that handles two category-related effects: + * 1. Resets cleared categories back to their last saved value + * 2. Sets the default distance category for distance requests + */ +function CategoryDefaultsSetter({ + transactions, + transactionIDs, + existingTransaction, + policyCategories, + policy, + isDistanceRequest, + requestType, + isMovingTransactionFromTrackExpense, +}: CategoryDefaultsSetterProps) { + useEffect(() => { + for (const item of transactions) { + if (!item.category) { + // If the expense had his category cleared due to unsaved changes (i.e. changing to recipient to one that does not have category) + // then we should reset the category to it's last saved value + const existingCategory = existingTransaction?.category; + if (existingCategory) { + const isExistingCategoryEnabled = policyCategories?.[existingCategory]?.enabled; + if (isExistingCategoryEnabled) { + setMoneyRequestCategory(item.transactionID, existingCategory, policy); + } + } + continue; + } + } + // We don't want to clear out category every time the transactions change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [policy?.id, policyCategories, transactions.length]); + + const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); + const defaultCategory = policyDistance?.defaultCategory ?? ''; + + useEffect(() => { + for (const item of transactions) { + if (!isDistanceRequest || !!item?.category) { + continue; + } + setMoneyRequestCategory(item.transactionID, defaultCategory, policy, isMovingTransactionFromTrackExpense); + } + // Prevent resetting to default when unselect category + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transactionIDs, requestType, defaultCategory, policy?.id]); + + return null; +} + +CategoryDefaultsSetter.displayName = 'CategoryDefaultsSetter'; + +export default CategoryDefaultsSetter; diff --git a/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx new file mode 100644 index 000000000000..50a70a28161d --- /dev/null +++ b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx @@ -0,0 +1,45 @@ +import {useEffect} from 'react'; +import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; + +type DraftWorkspaceOpenerProps = { + isCreatingTrackExpense: boolean; + policyID: string | undefined; + policyPendingAction: PendingAction | undefined; + policyExpenseChatPolicyID: string | undefined; + senderPolicyID: string | undefined; + isOffline: boolean; +}; + +/** + * Renderless component that opens draft workspace requests when needed. + * Handles two cases: + * 1. When creating a track expense, opens workspace for the policy + * 2. When a policy expense chat or sender policy is present, opens that workspace + */ +function DraftWorkspaceOpener({isCreatingTrackExpense, policyID, policyPendingAction, policyExpenseChatPolicyID, senderPolicyID, isOffline}: DraftWorkspaceOpenerProps) { + useEffect(() => { + if (!isCreatingTrackExpense || policyID === undefined) { + return; + } + + openDraftWorkspaceRequest(policyID); + }, [isCreatingTrackExpense, policyPendingAction, policyID]); + + useEffect(() => { + if (policyExpenseChatPolicyID && policyPendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + openDraftWorkspaceRequest(policyExpenseChatPolicyID); + return; + } + if (senderPolicyID && policyPendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + openDraftWorkspaceRequest(senderPolicyID); + } + }, [isOffline, policyPendingAction, policyExpenseChatPolicyID, senderPolicyID]); + + return null; +} + +DraftWorkspaceOpener.displayName = 'DraftWorkspaceOpener'; + +export default DraftWorkspaceOpener; diff --git a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx new file mode 100644 index 000000000000..ff342c436d03 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx @@ -0,0 +1,46 @@ +import {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {setMoneyRequestBillable, setMoneyRequestReimbursable} from '@userActions/IOU'; +import type {Policy} from '@src/types/onyx'; + +type ExpenseDefaultsSetterProps = { + transactionIDs: string[]; + policy: OnyxEntry; + isPolicyExpenseChat: boolean | undefined; + isMovingTransactionFromTrackExpense: boolean; + isCreatingTrackExpense: boolean; +}; + +/** + * Renderless component that sets default billable and reimbursable values + * on transactions based on the policy configuration. + */ +function ExpenseDefaultsSetter({transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense}: ExpenseDefaultsSetterProps) { + const defaultBillable = !!policy?.defaultBillable; + + useEffect(() => { + if (isMovingTransactionFromTrackExpense) { + return; + } + for (const transactionID of transactionIDs) { + setMoneyRequestBillable(transactionID, defaultBillable); + } + }, [transactionIDs, defaultBillable, isMovingTransactionFromTrackExpense]); + + useEffect(() => { + if (isMovingTransactionFromTrackExpense) { + return; + } + const defaultReimbursable = (isPolicyExpenseChat && isPaidGroupPolicy(policy)) || isCreatingTrackExpense ? (policy?.defaultReimbursable ?? true) : true; + for (const transactionID of transactionIDs) { + setMoneyRequestReimbursable(transactionID, defaultReimbursable); + } + }, [transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense]); + + return null; +} + +ExpenseDefaultsSetter.displayName = 'ExpenseDefaultsSetter'; + +export default ExpenseDefaultsSetter; diff --git a/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx new file mode 100644 index 000000000000..6c9613efae50 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx @@ -0,0 +1,44 @@ +import {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {generateReportID} from '@libs/ReportUtils'; +import {startMoneyRequest} from '@userActions/IOU'; +import type {IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import type {Transaction} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type MoneyRequestInitializerProps = { + isLoadingTransaction: boolean; + transaction: OnyxEntry; + iouType: IOUType; + reportID: string; + draftTransactionIDs: string[] | undefined; +}; + +/** + * Renderless component that initializes a money request (calls startMoneyRequest) + * when the transaction loads for the first time and has no participants yet. + */ +function MoneyRequestInitializer({isLoadingTransaction, transaction, iouType, reportID, draftTransactionIDs}: MoneyRequestInitializerProps) { + useEffect(() => { + // Exit early if the transaction is still loading + if (!!isLoadingTransaction || (transaction?.transactionID && (!transaction?.isFromGlobalCreate || !isEmptyObject(transaction?.participants)))) { + return; + } + + startMoneyRequest( + iouType ?? CONST.IOU.TYPE.CREATE, + // When starting to create an expense from the global FAB, If there is not an existing report yet, a random optimistic reportID is generated and used + // for all of the routes in the creation flow. + reportID ?? generateReportID(), + draftTransactionIDs, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again + }, [isLoadingTransaction]); + + return null; +} + +MoneyRequestInitializer.displayName = 'MoneyRequestInitializer'; + +export default MoneyRequestInitializer; diff --git a/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx new file mode 100644 index 000000000000..e68855905528 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx @@ -0,0 +1,50 @@ +import {useEffect} from 'react'; +import {cancelSpan, endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; +import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; + +type TelemetrySpanManagerProps = { + iouType: IOUType; +}; + +/** + * Renderless component that manages telemetry spans for the confirmation step. + * On mount: ends the open/mount spans, starts list-ready and receipt-load spans. + * On unmount: cancels any still-open child spans. + */ +function TelemetrySpanManager({iouType}: TelemetrySpanManagerProps) { + useEffect(() => { + endSpan(CONST.TELEMETRY.SPAN_OPEN_CREATE_EXPENSE); + endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_MOUNT); + + // Grab parent ref before ending it — children need it for parent_span_id linking + const parentSpan = getSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + + startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, { + name: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, + op: CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY, + parentSpan, + attributes: {[CONST.TELEMETRY.ATTRIBUTE_IOU_TYPE]: iouType}, + }); + startSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, { + name: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, + op: CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD, + parentSpan, + }); + + // End parent AFTER children are created — Sentry preserves parent_span_id regardless + endSpan(CONST.TELEMETRY.SPAN_SHUTTER_TO_CONFIRMATION); + + return () => { + cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY); + cancelSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_RECEIPT_LOAD); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this to run on mount/unmount + }, []); + + return null; +} + +TelemetrySpanManager.displayName = 'TelemetrySpanManager'; + +export default TelemetrySpanManager; From ab22b0b8a3e81f120665511bb4bd3e1519098ec5 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 1 Apr 2026 13:13:58 +0200 Subject: [PATCH 2/8] fix spell check --- .../iou/request/step/confirmation/CategoryDefaultsSetter.tsx | 2 +- .../iou/request/step/confirmation/DraftWorkspaceOpener.tsx | 2 +- .../iou/request/step/confirmation/ExpenseDefaultsSetter.tsx | 2 +- .../iou/request/step/confirmation/MoneyRequestInitializer.tsx | 2 +- .../iou/request/step/confirmation/TelemetrySpanManager.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx index c9362d62a884..3ef579a47008 100644 --- a/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx +++ b/src/pages/iou/request/step/confirmation/CategoryDefaultsSetter.tsx @@ -16,7 +16,7 @@ type CategoryDefaultsSetterProps = { }; /** - * Renderless component that handles two category-related effects: + * Side-effect-only component that handles two category-related effects: * 1. Resets cleared categories back to their last saved value * 2. Sets the default distance category for distance requests */ diff --git a/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx index 50a70a28161d..ac9654a7220b 100644 --- a/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx +++ b/src/pages/iou/request/step/confirmation/DraftWorkspaceOpener.tsx @@ -13,7 +13,7 @@ type DraftWorkspaceOpenerProps = { }; /** - * Renderless component that opens draft workspace requests when needed. + * Side-effect-only component that opens draft workspace requests when needed. * Handles two cases: * 1. When creating a track expense, opens workspace for the policy * 2. When a policy expense chat or sender policy is present, opens that workspace diff --git a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx index ff342c436d03..1e3d5557279e 100644 --- a/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx +++ b/src/pages/iou/request/step/confirmation/ExpenseDefaultsSetter.tsx @@ -13,7 +13,7 @@ type ExpenseDefaultsSetterProps = { }; /** - * Renderless component that sets default billable and reimbursable values + * Side-effect-only component that sets default billable and reimbursable values * on transactions based on the policy configuration. */ function ExpenseDefaultsSetter({transactionIDs, policy, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, isCreatingTrackExpense}: ExpenseDefaultsSetterProps) { diff --git a/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx index 6c9613efae50..d6ce8dc3ecb8 100644 --- a/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx +++ b/src/pages/iou/request/step/confirmation/MoneyRequestInitializer.tsx @@ -16,7 +16,7 @@ type MoneyRequestInitializerProps = { }; /** - * Renderless component that initializes a money request (calls startMoneyRequest) + * Side-effect-only component that initializes a money request (calls startMoneyRequest) * when the transaction loads for the first time and has no participants yet. */ function MoneyRequestInitializer({isLoadingTransaction, transaction, iouType, reportID, draftTransactionIDs}: MoneyRequestInitializerProps) { diff --git a/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx index e68855905528..617ff656ecef 100644 --- a/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx +++ b/src/pages/iou/request/step/confirmation/TelemetrySpanManager.tsx @@ -8,7 +8,7 @@ type TelemetrySpanManagerProps = { }; /** - * Renderless component that manages telemetry spans for the confirmation step. + * Side-effect-only component that manages telemetry spans for the confirmation step. * On mount: ends the open/mount spans, starts list-ready and receipt-load spans. * On unmount: cancels any still-open child spans. */ From 641d33cec431f65044a6295124188a303c8251ec Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 1 Apr 2026 16:29:19 +0200 Subject: [PATCH 3/8] extract two more components --- .../step/IOURequestStepConfirmation.tsx | 147 +++--------------- .../confirmation/OdometerReceiptStitcher.tsx | 100 ++++++++++++ .../confirmation/ReceiptFileValidator.tsx | 114 ++++++++++++++ 3 files changed, 239 insertions(+), 122 deletions(-) create mode 100644 src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx create mode 100644 src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 496b4c313b8b..6a08dd1517d3 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -39,8 +39,6 @@ import {getCurrencySymbol} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; -import {getMimeTypeFromUri, isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; -import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import getCurrentPosition from '@libs/getCurrentPosition'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getGPSCoordinates} from '@libs/GPSDraftDetailsUtils'; @@ -66,7 +64,6 @@ import { isReportOutstanding, isSelectedManagerMcTest, } from '@libs/ReportUtils'; -import stitchOdometerImages from '@libs/stitchOdometerImages'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; import getSubmitExpenseScenario from '@libs/telemetry/getSubmitExpenseScenario'; import markSubmitExpenseEnd from '@libs/telemetry/markSubmitExpenseEnd'; @@ -101,7 +98,7 @@ import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpe import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; -import {removeDraftTransaction, removeDraftTransactionsByIDs, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; +import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -118,6 +115,8 @@ import CategoryDefaultsSetter from './confirmation/CategoryDefaultsSetter'; import DraftWorkspaceOpener from './confirmation/DraftWorkspaceOpener'; import ExpenseDefaultsSetter from './confirmation/ExpenseDefaultsSetter'; import MoneyRequestInitializer from './confirmation/MoneyRequestInitializer'; +import OdometerReceiptStitcher from './confirmation/OdometerReceiptStitcher'; +import ReceiptFileValidator from './confirmation/ReceiptFileValidator'; import TelemetrySpanManager from './confirmation/TelemetrySpanManager'; import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -374,63 +373,6 @@ function IOURequestStepConfirmation({ const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; - useEffect(() => { - if (!isOdometerDistanceRequest) { - return; - } - - const getImageUri = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? img : (img?.uri ?? '')); - const getImageName = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img?.name ?? '')); - const getImageType = (img: FileObject | string | null | undefined): string | undefined => - typeof img === 'string' ? getMimeTypeFromUri(img) : (img?.type ?? getMimeTypeFromUri(img?.uri ?? '')); - - if (!odometerStartImage || !odometerEndImage) { - const singleImage = odometerStartImage ?? odometerEndImage; - - if (!singleImage) { - return; - } - - setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action, iouType), getImageType(singleImage)); - return; - } - - let ignore = false; - setIsStitchingReceipt(true); - setStitchError(''); - - stitchOdometerImages(odometerStartImage, odometerEndImage) - .then((stitchedImage) => { - if (ignore || !stitchedImage) { - return; - } - setMoneyRequestReceipt( - currentTransactionID, - getImageUri(stitchedImage), - getImageName(stitchedImage), - shouldUseTransactionDraft(action, iouType), - getImageType(stitchedImage), - ); - }) - .catch((error: unknown) => { - if (ignore) { - return; - } - Log.warn('stitchOdometerImages failed', {error}); - setStitchError(translate('iou.error.stitchOdometerImagesFailed')); - }) - .finally(() => { - if (ignore) { - return; - } - setIsStitchingReceipt(false); - }); - - return () => { - ignore = true; - }; - }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType]); - const navigateBack = useCallback(() => { if (backTo) { Navigation.goBack(backTo); @@ -495,67 +437,6 @@ function IOURequestStepConfirmation({ backToReport, ]); - // When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow. - // This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then - // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process. - // skip this in case user is moving the transaction as the receipt path will be valid in that case - useEffect(() => { - let newReceiptFiles: Record = {}; - let isScanFilesCanBeRead = true; - - Promise.all( - transactions.map((item) => { - const itemReceiptFilename = item.receipt?.filename; - const itemReceiptPath = item.receipt?.source; - const itemReceiptType = item.receipt?.type; - const isLocalFile = isLocalFileFileUtils(itemReceiptPath); - - if (!isLocalFile) { - if (item.receipt) { - newReceiptFiles = {...newReceiptFiles, [item.transactionID]: item.receipt}; - } - return Promise.resolve(); - } - - const onSuccess = (file: File) => { - const receipt: Receipt = file; - if (item?.receipt?.isTestReceipt) { - receipt.isTestReceipt = true; - receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE; - } else if (item?.receipt?.isTestDriveReceipt) { - receipt.isTestDriveReceipt = true; - receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE; - } else { - receipt.state = file && requestType === CONST.IOU.REQUEST_TYPE.MANUAL ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY; - } - - newReceiptFiles = {...newReceiptFiles, [item.transactionID]: receipt}; - }; - - const onFailure = () => { - isScanFilesCanBeRead = false; - if (initialTransactionID === item.transactionID) { - setMoneyRequestReceipt(item.transactionID, '', '', true, ''); - } - }; - - return validateReceiptFile(itemReceiptFilename, itemReceiptPath, itemReceiptType, onSuccess, onFailure) ?? Promise.resolve(); - }), - ).then(() => { - if (isScanFilesCanBeRead) { - setReceiptFiles(newReceiptFiles); - return; - } - if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) { - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, initialTransactionID, reportID, Navigation.getActiveRouteWithoutParams())); - return; - } - removeDraftTransactionsByIDs(draftTransactionIDs, true); - navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes - }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants]); - const requestMoney = useCallback( (selectedParticipants: Participant[], gpsPoint?: GpsPoint) => { if (!transactions.length) { @@ -1533,6 +1414,28 @@ function IOURequestStepConfirmation({ requestType={requestType} isMovingTransactionFromTrackExpense={isMovingTransactionFromTrackExpense} /> + + void; + onStitchError: (error: string) => void; +}; + +/** + * Side-effect-only component that stitches two odometer images into a single + * receipt, or sets a single odometer image as the receipt when only one exists. + */ +function OdometerReceiptStitcher({ + isOdometerDistanceRequest, + currentTransactionID, + odometerStartImage, + odometerEndImage, + action, + iouType, + onStitchingChange, + onStitchError, +}: OdometerReceiptStitcherProps) { + const {translate} = useLocalize(); + + useEffect(() => { + if (!isOdometerDistanceRequest) { + return; + } + + const getImageUri = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? img : (img?.uri ?? '')); + const getImageName = (img: FileObject | string | null | undefined): string => (typeof img === 'string' ? (img.split('/').pop() ?? '') : (img?.name ?? '')); + const getImageType = (img: FileObject | string | null | undefined): string | undefined => + typeof img === 'string' ? getMimeTypeFromUri(img) : (img?.type ?? getMimeTypeFromUri(img?.uri ?? '')); + + if (!odometerStartImage || !odometerEndImage) { + const singleImage = odometerStartImage ?? odometerEndImage; + + if (!singleImage) { + return; + } + + setMoneyRequestReceipt(currentTransactionID, getImageUri(singleImage), getImageName(singleImage), shouldUseTransactionDraft(action, iouType), getImageType(singleImage)); + return; + } + + let ignore = false; + onStitchingChange(true); + onStitchError(''); + + stitchOdometerImages(odometerStartImage, odometerEndImage) + .then((stitchedImage) => { + if (ignore || !stitchedImage) { + return; + } + setMoneyRequestReceipt( + currentTransactionID, + getImageUri(stitchedImage), + getImageName(stitchedImage), + shouldUseTransactionDraft(action, iouType), + getImageType(stitchedImage), + ); + }) + .catch((error: unknown) => { + if (ignore) { + return; + } + Log.warn('stitchOdometerImages failed', {error}); + onStitchError(translate('iou.error.stitchOdometerImagesFailed')); + }) + .finally(() => { + if (ignore) { + return; + } + onStitchingChange(false); + }); + + return () => { + ignore = true; + }; + }, [isOdometerDistanceRequest, currentTransactionID, odometerStartImage, odometerEndImage, action, translate, iouType, onStitchingChange, onStitchError]); + + return null; +} + +OdometerReceiptStitcher.displayName = 'OdometerReceiptStitcher'; + +export default OdometerReceiptStitcher; diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx new file mode 100644 index 000000000000..4bd83aee1da2 --- /dev/null +++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx @@ -0,0 +1,114 @@ +import {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils'; +import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; +import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {setMoneyRequestReceipt} from '@userActions/IOU'; +import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; +import CONST from '@src/CONST'; +import type {IOUAction, IOURequestType, IOUType} from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Report, Transaction} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type {Receipt} from '@src/types/onyx/Transaction'; + +type ReceiptFileValidatorProps = { + transactions: Transaction[]; + requestType: IOURequestType | undefined; + iouType: IOUType; + initialTransactionID: string; + reportID: string; + action: IOUAction; + report: OnyxEntry; + participants: Participant[]; + draftTransactionIDs: string[] | undefined; + onReceiptFilesChange: (files: Record) => void; +}; + +/** + * Side-effect-only component that validates receipt files on mount. + * If blob URLs have expired (e.g. after a browser refresh), navigates + * the user back to the start of the request flow to re-capture. + */ +function ReceiptFileValidator({ + transactions, + requestType, + iouType, + initialTransactionID, + reportID, + action, + report, + participants, + draftTransactionIDs, + onReceiptFilesChange, +}: ReceiptFileValidatorProps) { + // When the component mounts, if there is a receipt, see if the image can be read from the disk. If not, redirect the user to the starting step of the flow. + // This is because until the request is saved, the receipt file is only stored in the browsers memory as a blob:// and if the browser is refreshed, then + // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process. + // skip this in case user is moving the transaction as the receipt path will be valid in that case + useEffect(() => { + let newReceiptFiles: Record = {}; + let isScanFilesCanBeRead = true; + + Promise.all( + transactions.map((item) => { + const itemReceiptFilename = item.receipt?.filename; + const itemReceiptPath = item.receipt?.source; + const itemReceiptType = item.receipt?.type; + const isLocalFile = isLocalFileFileUtils(itemReceiptPath); + + if (!isLocalFile) { + if (item.receipt) { + newReceiptFiles = {...newReceiptFiles, [item.transactionID]: item.receipt}; + } + return Promise.resolve(); + } + + const onSuccess = (file: File) => { + const receipt: Receipt = file; + if (item?.receipt?.isTestReceipt) { + receipt.isTestReceipt = true; + receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE; + } else if (item?.receipt?.isTestDriveReceipt) { + receipt.isTestDriveReceipt = true; + receipt.state = CONST.IOU.RECEIPT_STATE.SCAN_COMPLETE; + } else { + receipt.state = file && requestType === CONST.IOU.REQUEST_TYPE.MANUAL ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY; + } + + newReceiptFiles = {...newReceiptFiles, [item.transactionID]: receipt}; + }; + + const onFailure = () => { + isScanFilesCanBeRead = false; + if (initialTransactionID === item.transactionID) { + setMoneyRequestReceipt(item.transactionID, '', '', true, ''); + } + }; + + return validateReceiptFile(itemReceiptFilename, itemReceiptPath, itemReceiptType, onSuccess, onFailure) ?? Promise.resolve(); + }), + ).then(() => { + if (isScanFilesCanBeRead) { + onReceiptFilesChange(newReceiptFiles); + return; + } + if (requestType === CONST.IOU.REQUEST_TYPE.MANUAL) { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute(CONST.IOU.ACTION.CREATE, iouType, initialTransactionID, reportID, Navigation.getActiveRouteWithoutParams())); + return; + } + removeDraftTransactionsByIDs(draftTransactionIDs, true); + if (requestType) { + navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID); + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes + }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants, onReceiptFilesChange]); + + return null; +} + +ReceiptFileValidator.displayName = 'ReceiptFileValidator'; + +export default ReceiptFileValidator; From 32ed6672cdf7c3f1b266169b388efdbf1cc65728 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 10:21:56 +0200 Subject: [PATCH 4/8] add async cleanup --- .../request/step/confirmation/ReceiptFileValidator.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx index 4bd83aee1da2..7f6904e45e7f 100644 --- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx +++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx @@ -48,6 +48,7 @@ function ReceiptFileValidator({ // the image ceases to exist. The best way for the user to recover from this is to start over from the start of the request process. // skip this in case user is moving the transaction as the receipt path will be valid in that case useEffect(() => { + let ignore = false; let newReceiptFiles: Record = {}; let isScanFilesCanBeRead = true; @@ -90,6 +91,9 @@ function ReceiptFileValidator({ return validateReceiptFile(itemReceiptFilename, itemReceiptPath, itemReceiptType, onSuccess, onFailure) ?? Promise.resolve(); }), ).then(() => { + if (ignore) { + return; + } if (isScanFilesCanBeRead) { onReceiptFilesChange(newReceiptFiles); return; @@ -103,6 +107,10 @@ function ReceiptFileValidator({ navigateToStartMoneyRequestStep(requestType, iouType, initialTransactionID, reportID); } }); + + return () => { + ignore = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps -- draftTransactionIDs is intentionally excluded to avoid re-running on draft changes }, [requestType, iouType, initialTransactionID, reportID, action, report, transactions, participants, onReceiptFilesChange]); From 281fe466d429a5ccb56267174b0fb88f8aa9cc88 Mon Sep 17 00:00:00 2001 From: Test Date: Thu, 2 Apr 2026 13:35:50 +0200 Subject: [PATCH 5/8] revert submodule changes --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 0d1a3c2c172a..1a9ab5eabe26 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 0d1a3c2c172a110f2c963a5f110be6e6077ebf52 +Subproject commit 1a9ab5eabe26ae841ed0d560c10844fe27488e43 From e2b86a7db731ebd51ef0cf2d2f094f89ee1fe37e Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Apr 2026 10:18:03 +0200 Subject: [PATCH 6/8] update comment --- .../iou/request/step/confirmation/ReceiptFileValidator.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx index 7f6904e45e7f..f8b7491f620e 100644 --- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx +++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx @@ -27,9 +27,9 @@ type ReceiptFileValidatorProps = { }; /** - * Side-effect-only component that validates receipt files on mount. - * If blob URLs have expired (e.g. after a browser refresh), navigates - * the user back to the start of the request flow to re-capture. + * Side-effect-only component that validates receipt files when transactions, + * participants, or report change. If blob URLs have expired (e.g. after a + * browser refresh), navigates the user back to the start of the request flow. */ function ReceiptFileValidator({ transactions, From 7a001d62cfbc18e5c78a3a4db09ea2a41b9985b3 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 7 Apr 2026 10:11:06 +0200 Subject: [PATCH 7/8] eslint fix --- src/pages/iou/request/step/IOURequestStepConfirmation.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 45dd3f47d1d5..9d47d9aad53f 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -90,7 +90,6 @@ import { setMoneyRequestParticipantsFromReport, setMoneyRequestReceipt, setMoneyRequestReimbursable, - startMoneyRequest, updateLastLocationPermissionPrompt, } from '@userActions/IOU'; import {submitPerDiemExpenseForSelfDM, submitPerDiemExpense as submitPerDiemExpenseIOUActions} from '@userActions/IOU/PerDiem'; @@ -98,8 +97,7 @@ import {getReceiverType, sendInvoice} from '@userActions/IOU/SendInvoice'; import {sendMoneyElsewhere, sendMoneyWithWallet} from '@userActions/IOU/SendMoney'; import {splitBill, splitBillAndOpenReport, startSplitBill} from '@userActions/IOU/Split'; import {requestMoney as requestMoneyIOUActions, trackExpense as trackExpenseIOUActions} from '@userActions/IOU/TrackExpense'; -import {openDraftWorkspaceRequest} from '@userActions/Policy/Policy'; -import {removeDraftTransaction, removeDraftTransactionsByIDs, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; +import {removeDraftTransaction, replaceDefaultDraftTransaction} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; From 142bd069661d4071eea2f2da529c782ce3c3902b Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 8 Apr 2026 09:43:55 +0200 Subject: [PATCH 8/8] eslint and ts fix --- .../iou/request/step/confirmation/OdometerReceiptStitcher.tsx | 2 +- .../iou/request/step/confirmation/ReceiptFileValidator.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx index cf88f3c6afab..2df5a7234d1c 100644 --- a/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx +++ b/src/pages/iou/request/step/confirmation/OdometerReceiptStitcher.tsx @@ -5,7 +5,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils'; import stitchOdometerImages from '@libs/stitchOdometerImages'; -import {setMoneyRequestReceipt} from '@userActions/IOU'; +import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; import type {IOUAction, IOUType} from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; diff --git a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx index f8b7491f620e..db77bec6ed63 100644 --- a/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx +++ b/src/pages/iou/request/step/confirmation/ReceiptFileValidator.tsx @@ -4,7 +4,7 @@ import {isLocalFile as isLocalFileFileUtils} from '@libs/fileDownload/FileUtils' import validateReceiptFile from '@libs/fileDownload/validateReceiptFile'; import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {setMoneyRequestReceipt} from '@userActions/IOU'; +import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; import {removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; import CONST from '@src/CONST'; import type {IOUAction, IOURequestType, IOUType} from '@src/CONST';