From e4ab982d82fed23a179fda2885b0210ae5a54cd5 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 30 Jun 2026 18:12:11 +0300 Subject: [PATCH 01/16] fix: use the committed baseline for money-request discard detection --- src/libs/TransactionUtils/index.ts | 16 +++++++++- .../iou/request/step/IOURequestStepAmount.tsx | 5 ++-- .../step/IOURequestStepDistanceManual.tsx | 4 +-- .../iou/request/step/IOURequestStepHours.tsx | 3 +- tests/unit/TransactionUtilsTest.ts | 30 ++++++++++++++++++- 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index cb6736c88411..da5c6f9dc40b 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1232,12 +1232,25 @@ function hasDisplayableMCC(mcc: number | string | null | undefined): boolean { */ /** * Whether a draft holds tab-entered input that is lost when the flow is abandoned (drafts are not restored on the next open). - * Forward-navigation fields (amount, receipt, ...) are deliberately excluded; extend per-field as new tabs persist input to the draft. + * Covers fields that persist to the draft as they are entered (waypoints). Forward-navigation fields that persist only on + * "Next" (amount, hours) live in the live input until then, so they use {@link hasUnsavedMoneyRequestInput} instead. */ function doesMoneyRequestDraftHaveUserInput(transaction: OnyxEntry): boolean { return Object.keys(getValidWaypoints(getWaypoints(transaction))).length > 0; } +/** + * Whether a money-request step's current value is unsaved — i.e. it differs from the last *committed* value. + * + * On the **create entry** (leaving discards the draft) nothing is committed, so the baseline is `emptyValue` and any + * entry counts; otherwise (editing, or leaving returns without discarding) the baseline is `committedValue`, so only a + * change counts. The live draft can't be the baseline on the create entry — "Next" persists into it, so a draft baseline + * would stop flagging the value once saved forward. + */ +function hasUnsavedMoneyRequestInput(currentValue: T, committedValue: T, emptyValue: T, isCreateEntry: boolean): boolean { + return currentValue !== (isCreateEntry ? emptyValue : committedValue); +} + function getWaypoints(transaction: OnyxEntry): WaypointCollection | undefined { return transaction?.modifiedWaypoints ?? transaction?.comment?.waypoints; } @@ -3021,6 +3034,7 @@ export { didReceiptScanSucceed, getValidWaypoints, doesMoneyRequestDraftHaveUserInput, + hasUnsavedMoneyRequestInput, haveWaypointAddressesChanged, isDistanceRequest, isMapDistanceRequest, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index e03283ffe901..8b54725d1f5c 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -24,7 +24,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, shouldEnableNegative} from '@libs/ReportUtils'; -import {getRequestType, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; +import {getRequestType, hasUnsavedMoneyRequestInput, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; import type {MoneyRequestAmountFormHandle} from '@pages/iou/MoneyRequestAmountForm'; import CONST from '@src/CONST'; @@ -122,11 +122,12 @@ function IOURequestStepAmount({ const [selectedCurrency, setSelectedCurrency] = useState(originalCurrency); const decimals = getCurrencyDecimals(selectedCurrency || CONST.CURRENCY.USD); + const isAmountCreateEntry = !backTo && !isEditing; const {notifySaving} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedAmount = amountFormRef.current?.getNumber() ?? ''; const typedAmountInBackendUnits = typedAmount ? convertToBackendAmount(Number.parseFloat(typedAmount)) : 0; - return typedAmountInBackendUnits !== transactionAmount || selectedCurrency !== originalCurrency; + return hasUnsavedMoneyRequestInput(typedAmountInBackendUnits, transactionAmount, 0, isAmountCreateEntry) || selectedCurrency !== originalCurrency; }, onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 68c01ba48463..3241f68d6acc 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -33,7 +33,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {generateReportID, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; -import {getDistanceInMeters} from '@libs/TransactionUtils'; +import {getDistanceInMeters, hasUnsavedMoneyRequestInput} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -147,7 +147,7 @@ function IOURequestStepDistanceManual({ const {notifySaving} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedDistance = numberFormRef.current?.getNumber() ?? ''; - return typedDistance !== (distance?.toString() ?? ''); + return hasUnsavedMoneyRequestInput(typedDistance, distance?.toString() ?? '', '', isCreatingNewRequest); }, onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); diff --git a/src/pages/iou/request/step/IOURequestStepHours.tsx b/src/pages/iou/request/step/IOURequestStepHours.tsx index f4144bd57d6d..6638f45e846f 100644 --- a/src/pages/iou/request/step/IOURequestStepHours.tsx +++ b/src/pages/iou/request/step/IOURequestStepHours.tsx @@ -20,6 +20,7 @@ import {getDefaultTimeTrackingRate} from '@libs/PolicyUtils'; import {getPolicyExpenseChat} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {computeTimeAmount, formatTimeMerchant} from '@libs/TimeTrackingUtils'; +import {hasUnsavedMoneyRequestInput} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import {setMoneyRequestAmount, setMoneyRequestMerchant, setMoneyRequestParticipantsFromReport, setMoneyRequestTimeCount, setMoneyRequestTimeRate} from '@userActions/IOU/MoneyRequest'; import CONST from '@src/CONST'; @@ -83,7 +84,7 @@ function IOURequestStepHours({ const {notifySaving} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedCount = moneyRequestTimeInputRef.current?.getNumber() ?? ''; - return typedCount !== `${transaction?.comment?.units?.count ?? ''}`; + return hasUnsavedMoneyRequestInput(typedCount, `${transaction?.comment?.units?.count ?? ''}`, '', isEmbeddedInStartPage); }, onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInputRef.current?.focus(), CONST.ANIMATED_TRANSITION); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 920fcc9f616c..f943249d2cbd 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1,7 +1,12 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import DateUtils from '@libs/DateUtils'; -import {doesMoneyRequestDraftHaveUserInput, shouldShowBrokenConnectionViolation, shouldShowBrokenConnectionViolationForMultipleTransactions} from '@libs/TransactionUtils'; +import { + doesMoneyRequestDraftHaveUserInput, + hasUnsavedMoneyRequestInput, + shouldShowBrokenConnectionViolation, + shouldShowBrokenConnectionViolationForMultipleTransactions, +} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -3644,3 +3649,26 @@ describe('doesMoneyRequestDraftHaveUserInput', () => { expect(doesMoneyRequestDraftHaveUserInput(transaction)).toBe(true); }); }); + +describe('hasUnsavedMoneyRequestInput', () => { + describe('create entry (committed baseline is empty)', () => { + it('is unsaved whenever a value is present, even when it already matches the draft', () => { + expect(hasUnsavedMoneyRequestInput(100, 0, 0, true)).toBe(true); + expect(hasUnsavedMoneyRequestInput(100, 100, 0, true)).toBe(true); + expect(hasUnsavedMoneyRequestInput('1', '', '', true)).toBe(true); + expect(hasUnsavedMoneyRequestInput('1', '1', '', true)).toBe(true); + }); + + it('is not unsaved when nothing was entered', () => { + expect(hasUnsavedMoneyRequestInput(0, 0, 0, true)).toBe(false); + expect(hasUnsavedMoneyRequestInput('', '', '', true)).toBe(false); + }); + }); + + describe('editing (committed baseline is the saved value)', () => { + it('is unsaved only when the value changed from the committed one', () => { + expect(hasUnsavedMoneyRequestInput(100, 50, 0, false)).toBe(true); + expect(hasUnsavedMoneyRequestInput(100, 100, 0, false)).toBe(false); + }); + }); +}); From d69432439a46713f8469431a6bd8dd395596b7c6 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 30 Jun 2026 19:50:31 +0300 Subject: [PATCH 02/16] fix: treat a typed zero as unsaved input on money-request create steps --- src/libs/MoneyRequestUtils.ts | 32 ++++++++- src/libs/TransactionUtils/index.ts | 15 +++-- .../iou/request/step/IOURequestStepAmount.tsx | 17 +++-- .../step/IOURequestStepDistanceManual.tsx | 5 +- .../iou/request/step/IOURequestStepHours.tsx | 4 +- tests/unit/MoneyRequestUtilsTest.ts | 67 ++++++++++++++++++- tests/unit/TransactionUtilsTest.ts | 33 +++++---- 7 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 959a4e3ea86f..88a7108e5455 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -2,10 +2,10 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {Report, Transaction} from '@src/types/onyx'; -import {convertToFrontendAmountAsInteger} from './CurrencyUtils'; +import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils'; import {isInvoiceReport, isIOUReport} from './ReportUtils'; import StringUtils from './StringUtils'; -import {isExpenseUnreported} from './TransactionUtils'; +import {hasUnsavedMoneyRequestInput, isExpenseUnreported} from './TransactionUtils'; import {isInvalidMerchantValue} from './ValidationUtils'; /** @@ -206,6 +206,32 @@ function isValidMerchant(merchant: string | undefined, transaction?: OnyxEntry) } /** - * Whether a money-request step's value is unsaved — i.e. it differs from the last *committed* value. + * Whether a money-request step's value is unsaved. * - * On the **create entry** (leaving discards the draft) the baseline is `emptyValue`, so any entry counts; otherwise it's - * `committedValue`, so only a change counts. The draft can't be the baseline here — "Next" persists into it, so it would - * stop flagging the value once saved forward. + * On the **create entry** (leaving discards the draft) any input counts, so it's unsaved whenever the current value + * isn't empty; otherwise only a change counts, so it's unsaved when the current value differs from the committed one. + * + * `isCurrentValueEmpty` is supplied by the caller because emptiness can live in a different space than the equality + * check: an amount is compared in backend units (where a typed "0" and an empty field both read as 0), but its + * emptiness must be judged on the raw input string ("0" is input, "" is not). */ -function hasUnsavedMoneyRequestInput(currentValue: T, committedValue: T, emptyValue: T, isCreateEntry: boolean): boolean { - return currentValue !== (isCreateEntry ? emptyValue : committedValue); +function hasUnsavedMoneyRequestInput(currentValue: T, committedValue: T, isCurrentValueEmpty: boolean, isCreateEntry: boolean): boolean { + return isCreateEntry ? !isCurrentValueEmpty : currentValue !== committedValue; } function getWaypoints(transaction: OnyxEntry): WaypointCollection | undefined { diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 8b54725d1f5c..62cc80c17e4a 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -19,12 +19,12 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; +import {getAmountHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, shouldEnableNegative} from '@libs/ReportUtils'; -import {getRequestType, hasUnsavedMoneyRequestInput, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; +import {getRequestType, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; import type {MoneyRequestAmountFormHandle} from '@pages/iou/MoneyRequestAmountForm'; import CONST from '@src/CONST'; @@ -124,11 +124,14 @@ function IOURequestStepAmount({ const isAmountCreateEntry = !backTo && !isEditing; const {notifySaving} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => { - const typedAmount = amountFormRef.current?.getNumber() ?? ''; - const typedAmountInBackendUnits = typedAmount ? convertToBackendAmount(Number.parseFloat(typedAmount)) : 0; - return hasUnsavedMoneyRequestInput(typedAmountInBackendUnits, transactionAmount, 0, isAmountCreateEntry) || selectedCurrency !== originalCurrency; - }, + getHasUnsavedChanges: () => + getAmountHasUnsavedChanges({ + typedAmount: amountFormRef.current?.getNumber() ?? '', + committedAmount: transactionAmount, + isCreateEntry: isAmountCreateEntry, + selectedCurrency, + originalCurrency, + }), onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); }, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 3241f68d6acc..ff2db33eaf09 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -29,11 +29,12 @@ import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {getStringFieldHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {generateReportID, isMoneyRequestReport, isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; -import {getDistanceInMeters, hasUnsavedMoneyRequestInput} from '@libs/TransactionUtils'; +import {getDistanceInMeters} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -147,7 +148,7 @@ function IOURequestStepDistanceManual({ const {notifySaving} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedDistance = numberFormRef.current?.getNumber() ?? ''; - return hasUnsavedMoneyRequestInput(typedDistance, distance?.toString() ?? '', '', isCreatingNewRequest); + return getStringFieldHasUnsavedChanges(typedDistance, distance?.toString() ?? '', isCreatingNewRequest); }, onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); diff --git a/src/pages/iou/request/step/IOURequestStepHours.tsx b/src/pages/iou/request/step/IOURequestStepHours.tsx index 6638f45e846f..86ae726d8c86 100644 --- a/src/pages/iou/request/step/IOURequestStepHours.tsx +++ b/src/pages/iou/request/step/IOURequestStepHours.tsx @@ -15,12 +15,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setTransactionReport} from '@libs/actions/Transaction'; import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabilities'; import {navigateToConfirmationPage, shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {getStringFieldHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultTimeTrackingRate} from '@libs/PolicyUtils'; import {getPolicyExpenseChat} from '@libs/ReportUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {computeTimeAmount, formatTimeMerchant} from '@libs/TimeTrackingUtils'; -import {hasUnsavedMoneyRequestInput} from '@libs/TransactionUtils'; import variables from '@styles/variables'; import {setMoneyRequestAmount, setMoneyRequestMerchant, setMoneyRequestParticipantsFromReport, setMoneyRequestTimeCount, setMoneyRequestTimeRate} from '@userActions/IOU/MoneyRequest'; import CONST from '@src/CONST'; @@ -84,7 +84,7 @@ function IOURequestStepHours({ const {notifySaving} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedCount = moneyRequestTimeInputRef.current?.getNumber() ?? ''; - return hasUnsavedMoneyRequestInput(typedCount, `${transaction?.comment?.units?.count ?? ''}`, '', isEmbeddedInStartPage); + return getStringFieldHasUnsavedChanges(typedCount, `${transaction?.comment?.units?.count ?? ''}`, isEmbeddedInStartPage); }, onCancel: () => { focusTimeoutRef.current = setTimeout(() => textInputRef.current?.focus(), CONST.ANIMATED_TRANSITION); diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 13decab07708..1dfd7117ada2 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -1,5 +1,13 @@ import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; -import {handleNegativeAmountFlipping, isValidMerchant, isValidMoneyRequestAmount, validateAmount, validatePercentage} from '@libs/MoneyRequestUtils'; +import { + getAmountHasUnsavedChanges, + getStringFieldHasUnsavedChanges, + handleNegativeAmountFlipping, + isValidMerchant, + isValidMoneyRequestAmount, + validateAmount, + validatePercentage, +} from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import type Report from '@src/types/onyx/Report'; import type Transaction from '@src/types/onyx/Transaction'; @@ -313,3 +321,60 @@ describe('ReportActionsUtils', () => { }); }); }); + +describe('getAmountHasUnsavedChanges', () => { + const sameCurrency = {selectedCurrency: 'USD', originalCurrency: 'USD'}; + + describe('create entry (any input counts)', () => { + it('flags a typed value', () => { + expect(getAmountHasUnsavedChanges({...sameCurrency, typedAmount: '1', committedAmount: 100, isCreateEntry: true})).toBe(true); + }); + + it('flags an explicit "0" even though it normalizes to the empty backend value', () => { + expect(getAmountHasUnsavedChanges({...sameCurrency, typedAmount: '0', committedAmount: 100, isCreateEntry: true})).toBe(true); + }); + + it('does not flag an empty field', () => { + expect(getAmountHasUnsavedChanges({...sameCurrency, typedAmount: '', committedAmount: 0, isCreateEntry: true})).toBe(false); + }); + + it('flags a currency change even with no amount entered', () => { + expect(getAmountHasUnsavedChanges({typedAmount: '', committedAmount: 0, isCreateEntry: true, selectedCurrency: 'EUR', originalCurrency: 'USD'})).toBe(true); + }); + }); + + describe('editing (only a real change counts)', () => { + it('does not flag formatting-only differences like "5" vs "5.00"', () => { + expect(getAmountHasUnsavedChanges({...sameCurrency, typedAmount: '5', committedAmount: 500, isCreateEntry: false})).toBe(false); + expect(getAmountHasUnsavedChanges({...sameCurrency, typedAmount: '5.00', committedAmount: 500, isCreateEntry: false})).toBe(false); + }); + + it('flags a real numeric change', () => { + expect(getAmountHasUnsavedChanges({...sameCurrency, typedAmount: '6', committedAmount: 500, isCreateEntry: false})).toBe(true); + }); + + it('flags a currency change even when the amount is unchanged', () => { + expect(getAmountHasUnsavedChanges({typedAmount: '5', committedAmount: 500, isCreateEntry: false, selectedCurrency: 'EUR', originalCurrency: 'USD'})).toBe(true); + }); + }); +}); + +describe('getStringFieldHasUnsavedChanges (hours, manual distance)', () => { + describe('create entry (any input counts)', () => { + it('flags any typed value, including "0"', () => { + expect(getStringFieldHasUnsavedChanges('2', '', true)).toBe(true); + expect(getStringFieldHasUnsavedChanges('0', '', true)).toBe(true); + }); + + it('does not flag an empty field', () => { + expect(getStringFieldHasUnsavedChanges('', '', true)).toBe(false); + }); + }); + + describe('editing (only a change counts)', () => { + it('flags only a change from the committed value', () => { + expect(getStringFieldHasUnsavedChanges('2', '2', false)).toBe(false); + expect(getStringFieldHasUnsavedChanges('3', '2', false)).toBe(true); + }); + }); +}); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 5ce48603017b..13d18ebdae3c 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -3854,24 +3854,31 @@ describe('doesMoneyRequestDraftHaveUserInput', () => { }); describe('hasUnsavedMoneyRequestInput', () => { - describe('create entry (committed baseline is empty)', () => { - it('is unsaved whenever a value is present, even when it already matches the draft', () => { - expect(hasUnsavedMoneyRequestInput(100, 0, 0, true)).toBe(true); - expect(hasUnsavedMoneyRequestInput(100, 100, 0, true)).toBe(true); - expect(hasUnsavedMoneyRequestInput('1', '', '', true)).toBe(true); - expect(hasUnsavedMoneyRequestInput('1', '1', '', true)).toBe(true); + describe('create entry (any input counts)', () => { + it('is unsaved whenever the current value is not empty, even when it matches the committed value', () => { + expect(hasUnsavedMoneyRequestInput(100, 0, false, true)).toBe(true); + expect(hasUnsavedMoneyRequestInput(100, 100, false, true)).toBe(true); + expect(hasUnsavedMoneyRequestInput('1', '1', false, true)).toBe(true); }); - it('is not unsaved when nothing was entered', () => { - expect(hasUnsavedMoneyRequestInput(0, 0, 0, true)).toBe(false); - expect(hasUnsavedMoneyRequestInput('', '', '', true)).toBe(false); + it('is unsaved for a typed "0" that normalizes to the empty value, e.g. an amount in backend units', () => { + expect(hasUnsavedMoneyRequestInput(0, 100, false, true)).toBe(true); + }); + + it('is not unsaved when the current value is empty', () => { + expect(hasUnsavedMoneyRequestInput(0, 0, true, true)).toBe(false); + expect(hasUnsavedMoneyRequestInput('', '', true, true)).toBe(false); }); }); - describe('editing (committed baseline is the saved value)', () => { - it('is unsaved only when the value changed from the committed one', () => { - expect(hasUnsavedMoneyRequestInput(100, 50, 0, false)).toBe(true); - expect(hasUnsavedMoneyRequestInput(100, 100, 0, false)).toBe(false); + describe('editing (only a change counts)', () => { + it('is unsaved only when the current value differs from the committed one', () => { + expect(hasUnsavedMoneyRequestInput(100, 50, false, false)).toBe(true); + expect(hasUnsavedMoneyRequestInput(100, 100, false, false)).toBe(false); + }); + + it('ignores the emptiness flag — only the current/committed comparison matters', () => { + expect(hasUnsavedMoneyRequestInput(0, 100, true, false)).toBe(true); }); }); }); From 3f44c542f60f08d0f6750959e7da9348eaa44414 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Tue, 30 Jun 2026 20:16:38 +0300 Subject: [PATCH 03/16] fix: show the discard modal when editing waypoints on a distance expense --- src/libs/MoneyRequestUtils.ts | 17 +++++++- .../request/step/IOURequestStepDistance.tsx | 5 ++- .../step/IOURequestStepDistanceMap.tsx | 5 ++- tests/unit/MoneyRequestUtilsTest.ts | 39 ++++++++++++++++++- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 88a7108e5455..e76e10a04456 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -2,10 +2,11 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import type {Report, Transaction} from '@src/types/onyx'; +import type {WaypointCollection} from '@src/types/onyx/Transaction'; import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils'; import {isInvoiceReport, isIOUReport} from './ReportUtils'; import StringUtils from './StringUtils'; -import {hasUnsavedMoneyRequestInput, isExpenseUnreported} from './TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, hasUnsavedMoneyRequestInput, haveWaypointAddressesChanged, isExpenseUnreported} from './TransactionUtils'; import {isInvalidMerchantValue} from './ValidationUtils'; /** @@ -232,6 +233,19 @@ function getStringFieldHasUnsavedChanges(typedValue: string, committedValue: str return hasUnsavedMoneyRequestInput(typedValue, committedValue, typedValue === '', isCreateEntry); } +/** + * Whether the distance (map) step has unsaved waypoints: on the create entry any entered waypoint counts, otherwise + * it's whether the waypoint addresses changed from the committed ones. + */ +function getWaypointsHasUnsavedChanges( + transaction: OnyxEntry, + committedWaypoints: WaypointCollection | undefined, + currentWaypoints: WaypointCollection | undefined, + isCreateEntry: boolean, +): boolean { + return isCreateEntry ? doesMoneyRequestDraftHaveUserInput(transaction) : haveWaypointAddressesChanged(committedWaypoints, currentWaypoints); +} + export { addLeadingZero, replaceAllDigits, @@ -247,4 +261,5 @@ export { isValidMerchant, getAmountHasUnsavedChanges, getStringFieldHasUnsavedChanges, + getWaypointsHasUnsavedChanges, }; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index b8f5ede917d8..d287b3272d99 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -39,11 +39,12 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {getWaypointsHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {doesMoneyRequestDraftHaveUserInput, getDistanceInMeters, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {getDistanceInMeters, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -157,7 +158,7 @@ function IOURequestStepDistance({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); const {notifySaving} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => isCreatingNewRequest && doesMoneyRequestDraftHaveUserInput(transaction), + getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, transactionBackup?.comment?.waypoints, waypoints, isCreatingNewRequest), }); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 794f0ea39335..edd48ceff5c2 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -33,9 +33,10 @@ import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypo import {getLatestErrorField} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; +import {getWaypointsHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {doesMoneyRequestDraftHaveUserInput, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -140,7 +141,7 @@ function IOURequestStepDistanceMap({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); const {notifySaving} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => isCreatingNewRequest && doesMoneyRequestDraftHaveUserInput(transaction), + getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, transactionBackup?.comment?.waypoints, waypoints, isCreatingNewRequest), }); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 1dfd7117ada2..758f6c1aa246 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -2,6 +2,7 @@ import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem'; import { getAmountHasUnsavedChanges, getStringFieldHasUnsavedChanges, + getWaypointsHasUnsavedChanges, handleNegativeAmountFlipping, isValidMerchant, isValidMoneyRequestAmount, @@ -11,7 +12,8 @@ import { import CONST from '@src/CONST'; import type Report from '@src/types/onyx/Report'; import type Transaction from '@src/types/onyx/Transaction'; -import type {TransactionCustomUnit} from '@src/types/onyx/Transaction'; +import type {TransactionCustomUnit, WaypointCollection} from '@src/types/onyx/Transaction'; +import createRandomTransaction from '../utils/collections/transaction'; describe('ReportActionsUtils', () => { describe('validateAmount', () => { @@ -378,3 +380,38 @@ describe('getStringFieldHasUnsavedChanges (hours, manual distance)', () => { }); }); }); + +describe('getWaypointsHasUnsavedChanges (distance map)', () => { + const waypointsA: WaypointCollection = { + waypoint0: {address: 'Q Mall, Doha', lat: 25.3272762, lng: 51.4659325}, + waypoint1: {address: 'Qatar', lat: 25.354826, lng: 51.183884}, + }; + const waypointsB: WaypointCollection = { + waypoint0: {address: 'Q Mall, Doha', lat: 25.3272762, lng: 51.4659325}, + waypoint1: {address: 'West Walk - Salwa Road, Doha', lat: 25.2510416, lng: 51.4699357}, + }; + + describe('create entry (any entered waypoint counts)', () => { + it('flags a draft that has valid waypoints', () => { + const transaction = createRandomTransaction(0); + transaction.modifiedWaypoints = undefined; + transaction.comment = {...transaction.comment, waypoints: waypointsA}; + expect(getWaypointsHasUnsavedChanges(transaction, undefined, waypointsA, true)).toBe(true); + }); + + it('does not flag an empty draft', () => { + expect(getWaypointsHasUnsavedChanges(undefined, undefined, undefined, true)).toBe(false); + }); + }); + + // The edit branch ignores the transaction and compares the committed vs current waypoints directly. + describe('editing (only a waypoint change counts)', () => { + it('flags when a waypoint address changed from the committed one', () => { + expect(getWaypointsHasUnsavedChanges(undefined, waypointsA, waypointsB, false)).toBe(true); + }); + + it('does not flag when the waypoints are unchanged', () => { + expect(getWaypointsHasUnsavedChanges(undefined, waypointsA, waypointsA, false)).toBe(false); + }); + }); +}); From 30ed3c50a368235f9ac04fa439a839fb1c475a7d Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 12:27:42 +0300 Subject: [PATCH 04/16] fix: default native tab backBehavior to 'none' so back leaves tabbed RHP flows consistently --- src/libs/Navigation/OnyxTabNavigatorConfig/index.ts | 8 ++++++-- src/pages/iou/request/step/IOURequestStepDistance.tsx | 7 ++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts index 99f1ecded851..a57a7434a723 100644 --- a/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts +++ b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts @@ -4,7 +4,11 @@ const defaultScreenOptions = { animation: 'default', } as const; -/** On native there is no browser history; hardware back returns to the initial tab first, per platform convention. */ -const backBehavior: NonNullable = 'initialRoute'; +/** + * `none` keeps the tab history at a single entry, so back — hardware or header — leaves the whole flow instead of + * returning to the initial tab first. Every OnyxTabNavigator is an RHP/modal flow where back should dismiss it, so + * this matches web and the iOS swipe gesture. + */ +const backBehavior: NonNullable = 'none'; export {defaultScreenOptions, backBehavior}; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index d287b3272d99..0b51eaeedfb3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -317,11 +317,8 @@ function IOURequestStepDistance({ Navigation.closeRHPFlow(); }, [isEditingSplit, backTo]); - // In the edit flow this page is rendered inside an OnyxTabNavigator. A plain `goBack()` with no - // target would be swallowed by that tab navigator (reverting to the Map tab) instead of leaving - // the page, so the header back must leave explicitly: honor an explicit `backTo` (e.g. the - // edit-split page) and otherwise close the RHP — matching other tabbed RHP pages like - // IOURequestStartPage. The browser/hardware back keeps the default tab behavior (revert tab first). + // In the edit flow this page is rendered inside an OnyxTabNavigator. The header back honors an + // explicit `backTo` (e.g. the edit-split page) and otherwise leaves the whole flow. const navigateBackFromEditFlow = useCallback(() => { if (backTo) { Navigation.goBack(backTo); From 63890c9ccf16a53fbdef9c00d441e31214a6bf56 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 16:15:54 +0300 Subject: [PATCH 05/16] refactor: simplify discard-confirmation internals and money-request predicates --- .../index.native.ts | 22 ++--- .../useDiscardChangesConfirmation/index.ts | 81 ++++++++++--------- .../runDiscardConfirmation.ts | 17 ++++ .../useDiscardChangesConfirmation/types.ts | 9 +-- src/libs/MoneyRequestUtils.ts | 25 +++--- src/libs/TransactionUtils/index.ts | 25 +----- .../iou/request/step/IOURequestStepAmount.tsx | 4 +- .../request/step/IOURequestStepDistance.tsx | 6 +- .../step/IOURequestStepDistanceManual.tsx | 4 +- .../step/IOURequestStepDistanceMap.tsx | 6 +- .../iou/request/step/IOURequestStepHours.tsx | 6 +- .../Agents/Fields/EditAgentAvatarPage.tsx | 4 +- .../Profile/Avatar/EditUserAvatarContent.tsx | 8 +- tests/unit/TransactionUtilsTest.ts | 37 +-------- .../useDiscardChangesConfirmation.test.ts | 4 +- ...seDiscardChangesConfirmationNative.test.ts | 7 +- 16 files changed, 106 insertions(+), 159 deletions(-) create mode 100644 src/hooks/useDiscardChangesConfirmation/runDiscardConfirmation.ts diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 22e78df5376b..7872d0a8a0c6 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -5,10 +5,10 @@ import {BackHandler} from 'react-native'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; -import Log from '@libs/Log'; import navigationRef from '@libs/Navigation/navigationRef'; import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; +import runDiscardConfirmation from './runDiscardConfirmation'; import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; @@ -34,9 +34,7 @@ function useDiscardChangesConfirmation({ }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); - // Also guard tab switches when this screen is an OnyxTabNavigator tab. - // Self-disables outside a tab navigator or without an onTabSwitchDiscard handler - useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); + useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel); const showDiscardModal = (blockedAction?: NavigationAction) => { blockedNavigationAction.current = blockedAction; @@ -60,13 +58,9 @@ function useDiscardChangesConfirmation({ } isReplayingBlockedNavigation.current = false; }; - Promise.resolve() - .then(() => onConfirm?.()) - .then(confirmNavigation) - .catch((error: unknown) => { - Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); - blockedNavigationAction.current = undefined; - }); + runDiscardConfirmation(onConfirm, confirmNavigation, () => { + blockedNavigationAction.current = undefined; + }); }); }; @@ -98,11 +92,11 @@ function useDiscardChangesConfirmation({ return () => subscription.remove(); }); - const notifySaving = (isSaving = true) => { - isSavingRef.current = isSaving; + const suppressDiscardPrompt = (shouldSuppress = true) => { + isSavingRef.current = shouldSuppress; }; - return {notifySaving}; + return {suppressDiscardPrompt}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 2153586c6362..a6a9369003ca 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -5,14 +5,21 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; -import Log from '@libs/Log'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigationRef from '@libs/Navigation/navigationRef'; import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; +import runDiscardConfirmation from './runDiscardConfirmation'; import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; +/** + * Tracks the `history.go(1)` restore round-trip so its echo popstate isn't mistaken for a fresh back: `awaitingRestore` + * = a prevented reset awaiting its popstate; `restoring` = the `go(1)` in flight, awaiting its echo; `dismissModalOnRestore` + * = the back happened over the open prompt. + */ +type RestoreState = {phase: 'idle'} | {phase: 'awaitingRestore'; dismissModalOnRestore: boolean} | {phase: 'restoring'}; + function useDiscardChangesConfirmation({ getHasUnsavedChanges, onCancel, @@ -24,16 +31,6 @@ function useDiscardChangesConfirmation({ const {translate} = useLocalize(); const {showConfirmModal, closeModal} = useConfirmModal(); - // Also guard tab switches when this screen is an OnyxTabNavigator tab. - // Self-disables outside a tab navigator or without an onTabSwitchDiscard handler - useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); - const blockedNavigationAction = useRef(undefined); - const shouldNavigateBack = useRef(false); - const isDiscardModalOpen = useRef(false); - const isRestoringHistory = useRef(false); - const didPreventResetOnPopstate = useRef(false); - const shouldDismissModalOnRestore = useRef(false); - // Only the focused screen should prompt — a flow-leave reset fires `beforeRemove` for hidden siblings too. const isFocused = useIsFocused(); const isSavingRef = useRef(false); @@ -42,6 +39,13 @@ function useDiscardChangesConfirmation({ }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); + useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel); + + const blockedNavigationAction = useRef(undefined); + const shouldNavigateBack = useRef(false); + const isDiscardModalOpen = useRef(false); + const restoreState = useRef({phase: 'idle'}); + const navigateBack = () => { if (!blockedNavigationAction.current) { return; @@ -60,30 +64,30 @@ function useDiscardChangesConfirmation({ shouldHandleNavigationBack: false, }).then((result) => { isDiscardModalOpen.current = false; - didPreventResetOnPopstate.current = false; - shouldDismissModalOnRestore.current = false; + // The awaiting-restore reservation is only meaningful until the prompt resolves; an in-flight `restoring` must survive it. + if (restoreState.current.phase === 'awaitingRestore') { + restoreState.current = {phase: 'idle'}; + } onVisibilityChange?.(false); - if (result.action === ModalActions.CONFIRM) { - Promise.resolve() - .then(() => onConfirm?.()) - .then(() => { - setNavigationActionToMicrotaskQueue(navigateBack); - }) - .catch((error: unknown) => { - Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); - blockedNavigationAction.current = undefined; - shouldNavigateBack.current = false; - }); - } else { + if (result.action !== ModalActions.CONFIRM) { blockedNavigationAction.current = undefined; shouldNavigateBack.current = false; onCancel?.(); + return; } + runDiscardConfirmation( + onConfirm, + () => setNavigationActionToMicrotaskQueue(navigateBack), + () => { + blockedNavigationAction.current = undefined; + shouldNavigateBack.current = false; + }, + ); }); }; useBeforeRemove((e) => { - if (isRestoringHistory.current) { + if (restoreState.current.phase === 'restoring') { // The `history.go(1)` restoring the browser entry can re-deliver a reset for the current state; swallow it without re-blocking e.preventDefault(); return; @@ -96,8 +100,7 @@ function useDiscardChangesConfirmation({ if (isDiscardModalOpen.current) { e.preventDefault(); if (e.data.action.type === 'RESET') { - didPreventResetOnPopstate.current = true; - shouldDismissModalOnRestore.current = true; + restoreState.current = {phase: 'awaitingRestore', dismissModalOnRestore: true}; return; } closeModal(); @@ -112,7 +115,7 @@ function useDiscardChangesConfirmation({ blockedNavigationAction.current = e.data.action; if (e.data.action.type === 'RESET') { // A prevented RESET comes from a browser back; the popstate listener must restore the URL - didPreventResetOnPopstate.current = true; + restoreState.current = {phase: 'awaitingRestore', dismissModalOnRestore: false}; } showDiscardModal(); }); @@ -128,18 +131,16 @@ function useDiscardChangesConfirmation({ * already moved — this listener restores it with `history.go(1)`, and dismisses the prompt as Cancel when the back happened over it. */ useEffect(() => { - // Register once: the listener reads the latest `closeModal` through `closeModalRef`, so it never needs to re-subscribe const handlePopState = () => { - if (isRestoringHistory.current) { - isRestoringHistory.current = false; + const restore = restoreState.current; + if (restore.phase === 'restoring') { + restoreState.current = {phase: 'idle'}; return; } - if (didPreventResetOnPopstate.current) { - didPreventResetOnPopstate.current = false; - isRestoringHistory.current = true; + if (restore.phase === 'awaitingRestore') { + restoreState.current = {phase: 'restoring'}; window.history.go(1); - if (shouldDismissModalOnRestore.current) { - shouldDismissModalOnRestore.current = false; + if (restore.dismissModalOnRestore) { closeModalRef.current(); } } @@ -149,11 +150,11 @@ function useDiscardChangesConfirmation({ return () => window.removeEventListener('popstate', handlePopState); }, []); - const notifySaving = (isSaving = true) => { - isSavingRef.current = isSaving; + const suppressDiscardPrompt = (shouldSuppress = true) => { + isSavingRef.current = shouldSuppress; }; - return {notifySaving}; + return {suppressDiscardPrompt}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/runDiscardConfirmation.ts b/src/hooks/useDiscardChangesConfirmation/runDiscardConfirmation.ts new file mode 100644 index 000000000000..5a9ef86facfa --- /dev/null +++ b/src/hooks/useDiscardChangesConfirmation/runDiscardConfirmation.ts @@ -0,0 +1,17 @@ +import Log from '@libs/Log'; + +/** + * Await `onConfirm`, then replay the blocked navigation — but if `onConfirm` rejects, log and run `onError` WITHOUT + * navigating, so a failed discard leaves the user in place rather than proceeding silently. + */ +function runDiscardConfirmation(onConfirm: (() => void | Promise) | undefined, navigate: () => void, onError: () => void): void { + Promise.resolve() + .then(() => onConfirm?.()) + .then(navigate) + .catch((error: unknown) => { + Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); + onError(); + }); +} + +export default runDiscardConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index 5c35f489c4e4..fa42ee5b0d13 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -4,17 +4,12 @@ type UseDiscardChangesConfirmationOptions = { onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; onConfirm?: () => void | Promise; - - /** - * Discard action for confirming a tab switch. Provide it to guard tab switches inside an `OnyxTabNavigator`. - * Can differ from `onConfirm` (nav-away) - */ onTabSwitchDiscard?: () => void | Promise; }; type DiscardChangesConfirmation = { - /** Suppress the discard prompt while an intentional save navigates away. Pass `false` to clear it if the save aborts without navigating. */ - notifySaving: (isSaving?: boolean) => void; + /** Suppress the discard prompt during an intentional navigation (a save, or a redirect such as the billing restriction). Pass `false` to clear it if that navigation aborts without leaving. */ + suppressDiscardPrompt: (shouldSuppress?: boolean) => void; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index e76e10a04456..7650fea490ef 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -6,7 +6,7 @@ import type {WaypointCollection} from '@src/types/onyx/Transaction'; import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils'; import {isInvoiceReport, isIOUReport} from './ReportUtils'; import StringUtils from './StringUtils'; -import {doesMoneyRequestDraftHaveUserInput, hasUnsavedMoneyRequestInput, haveWaypointAddressesChanged, isExpenseUnreported} from './TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isExpenseUnreported} from './TransactionUtils'; import {isInvalidMerchantValue} from './ValidationUtils'; /** @@ -216,27 +216,24 @@ type AmountHasUnsavedChangesParams = { }; /** - * Whether the amount step has unsaved input. Emptiness is judged on the raw string so a typed "0" still counts, the - * change is compared in backend units so formatting-only differences like "5" vs "5.00" aren't flagged, and a currency - * change counts on its own. + * Whether the amount step has unsaved input. Emptiness is judged on the raw string (so a typed "0" counts) and the + * change in backend units (so "5" vs "5.00" isn't a false positive); a currency change counts on its own. */ function getAmountHasUnsavedChanges({typedAmount, committedAmount, isCreateEntry, selectedCurrency, originalCurrency}: AmountHasUnsavedChangesParams): boolean { + const currencyChanged = selectedCurrency !== originalCurrency; + if (isCreateEntry) { + return typedAmount !== '' || currencyChanged; + } const typedAmountInBackendUnits = typedAmount ? convertToBackendAmount(Number.parseFloat(typedAmount)) : 0; - return hasUnsavedMoneyRequestInput(typedAmountInBackendUnits, committedAmount, typedAmount === '', isCreateEntry) || selectedCurrency !== originalCurrency; + return typedAmountInBackendUnits !== committedAmount || currencyChanged; } -/** - * Whether a raw-string money-request step (hours, manual distance) has unsaved input: the typed value is non-empty on - * the create entry, or differs from the committed value when editing. - */ +/** Whether a raw-string money-request step (hours, manual distance) has unsaved input. */ function getStringFieldHasUnsavedChanges(typedValue: string, committedValue: string, isCreateEntry: boolean): boolean { - return hasUnsavedMoneyRequestInput(typedValue, committedValue, typedValue === '', isCreateEntry); + return isCreateEntry ? typedValue !== '' : typedValue !== committedValue; } -/** - * Whether the distance (map) step has unsaved waypoints: on the create entry any entered waypoint counts, otherwise - * it's whether the waypoint addresses changed from the committed ones. - */ +/** Whether the distance (map) step has unsaved waypoints. */ function getWaypointsHasUnsavedChanges( transaction: OnyxEntry, committedWaypoints: WaypointCollection | undefined, diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 61bb9c6395a7..cf9912c7641c 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1241,32 +1241,12 @@ function hasDisplayableMCC(mcc: number | string | null | undefined): boolean { return getMCCForDisplay(mcc) !== ''; } -/** - * Return the waypoints field from the transaction, return the modifiedWaypoints if present. - */ -/** - * Whether a draft holds tab-entered input that is lost when the flow is abandoned (drafts are not restored on the next open). - * Covers fields that persist to the draft as they are entered (waypoints). Forward-navigation fields that persist only on - * "Next" (amount, hours) live in the live input until then, so they use {@link hasUnsavedMoneyRequestInput} instead. - */ +/** Whether the draft holds tab-entered input (waypoints) that is lost when the flow is abandoned. */ function doesMoneyRequestDraftHaveUserInput(transaction: OnyxEntry): boolean { return Object.keys(getValidWaypoints(getWaypoints(transaction))).length > 0; } -/** - * Whether a money-request step's value is unsaved. - * - * On the **create entry** (leaving discards the draft) any input counts, so it's unsaved whenever the current value - * isn't empty; otherwise only a change counts, so it's unsaved when the current value differs from the committed one. - * - * `isCurrentValueEmpty` is supplied by the caller because emptiness can live in a different space than the equality - * check: an amount is compared in backend units (where a typed "0" and an empty field both read as 0), but its - * emptiness must be judged on the raw input string ("0" is input, "" is not). - */ -function hasUnsavedMoneyRequestInput(currentValue: T, committedValue: T, isCurrentValueEmpty: boolean, isCreateEntry: boolean): boolean { - return isCreateEntry ? !isCurrentValueEmpty : currentValue !== committedValue; -} - +/** Returns the transaction's waypoints, preferring `modifiedWaypoints` when present. */ function getWaypoints(transaction: OnyxEntry): WaypointCollection | undefined { return transaction?.modifiedWaypoints ?? transaction?.comment?.waypoints; } @@ -3123,7 +3103,6 @@ export { didReceiptScanSucceed, getValidWaypoints, doesMoneyRequestDraftHaveUserInput, - hasUnsavedMoneyRequestInput, haveWaypointAddressesChanged, isDistanceRequest, isMapDistanceRequest, diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 62cc80c17e4a..dc748bb59adb 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -123,7 +123,7 @@ function IOURequestStepAmount({ const decimals = getCurrencyDecimals(selectedCurrency || CONST.CURRENCY.USD); const isAmountCreateEntry = !backTo && !isEditing; - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => getAmountHasUnsavedChanges({ typedAmount: amountFormRef.current?.getNumber() ?? '', @@ -175,7 +175,7 @@ function IOURequestStepAmount({ }; const handleSubmit = ({amount, paymentMethod}: {amount: string; paymentMethod?: PaymentMethodType}) => { - notifySaving(); + suppressDiscardPrompt(); submitAmount({ report, transaction, diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 0b51eaeedfb3..bfc842e8113a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -157,7 +157,7 @@ function IOURequestStepDistance({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, transactionBackup?.comment?.waypoints, waypoints, isCreatingNewRequest), }); @@ -496,7 +496,7 @@ function IOURequestStepDistance({ return; } - notifySaving(); + suppressDiscardPrompt(); navigateToNextStep(); }, [ duplicateWaypointsError, @@ -508,7 +508,7 @@ function IOURequestStepDistance({ isCreatingNewRequest, navigateToNextStep, navigateBackAfterSave, - notifySaving, + suppressDiscardPrompt, isEditingSplit, originalSplitTransactionDraft, transactionBackup, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index ff2db33eaf09..6c24a6893d4a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -145,7 +145,7 @@ function IOURequestStepDistanceManual({ const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : unit); const distance = typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)) : undefined; - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedDistance = numberFormRef.current?.getNumber() ?? ''; return getStringFieldHasUnsavedChanges(typedDistance, distance?.toString() ?? '', isCreatingNewRequest); @@ -295,7 +295,7 @@ function IOURequestStepDistanceManual({ return; } - notifySaving(); + suppressDiscardPrompt(); navigateToNextPage(value); }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index edd48ceff5c2..538596df54b0 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -140,7 +140,7 @@ function IOURequestStepDistanceMap({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, transactionBackup?.comment?.waypoints, waypoints, isCreatingNewRequest), }); @@ -367,7 +367,7 @@ function IOURequestStepDistanceMap({ return; } - notifySaving(); + suppressDiscardPrompt(); navigateToNextStep(); }, [ duplicateWaypointsError, @@ -378,7 +378,7 @@ function IOURequestStepDistanceMap({ isLoading, isCreatingNewRequest, navigateToNextStep, - notifySaving, + suppressDiscardPrompt, isEditingSplit, transaction, transactionBackup, diff --git a/src/pages/iou/request/step/IOURequestStepHours.tsx b/src/pages/iou/request/step/IOURequestStepHours.tsx index 86ae726d8c86..aed0399478b3 100644 --- a/src/pages/iou/request/step/IOURequestStepHours.tsx +++ b/src/pages/iou/request/step/IOURequestStepHours.tsx @@ -81,7 +81,7 @@ function IOURequestStepHours({ moneyRequestTimeInputRef.current?.updateNumber(`${transaction?.comment?.units?.count ?? ''}`); }, [selectedTab, transaction?.comment?.units?.count]); - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedCount = moneyRequestTimeInputRef.current?.getNumber() ?? ''; return getStringFieldHasUnsavedChanges(typedCount, `${transaction?.comment?.units?.count ?? ''}`, isEmbeddedInStartPage); @@ -114,7 +114,7 @@ function IOURequestStepHours({ return; } - notifySaving(); + suppressDiscardPrompt(); setMoneyRequestAmount(transactionID, computeTimeAmount(rate, count), currency); setMoneyRequestMerchant(transactionID, formatTimeMerchant(count, rate, currency, translate, convertToDisplayString), isTransactionDraft); setMoneyRequestTimeCount(transactionID, count, isTransactionDraft); @@ -183,7 +183,7 @@ function IOURequestStepHours({ style={[styles.w100, canUseTouchScreen ? styles.mt5 : styles.mt0]} onPress={() => { if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { - notifySaving(); + suppressDiscardPrompt(); Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx index 155783630e55..06b159fd0a0c 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx @@ -75,7 +75,7 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset const isDirty = selectedBotAvatar !== initialBotAvatar || imageData.uri !== ''; - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => isDirty, }); @@ -122,7 +122,7 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset if (!isDirty) { return; } - notifySaving(); + suppressDiscardPrompt(); if (imageData.file) { if (onSave) { diff --git a/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx b/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx index 3239ede9e65e..79fd1d3e75e1 100644 --- a/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx +++ b/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx @@ -39,7 +39,7 @@ function EditUserAvatarContent() { const isDirty = imageData.uri !== '' || !!selected; - const {notifySaving} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => isDirty, }); @@ -64,7 +64,7 @@ function EditUserAvatarContent() { }; const onPress = () => { - notifySaving(); + suppressDiscardPrompt(); if (imageData.file) { updateAvatar(imageData.file, { @@ -95,7 +95,7 @@ function EditUserAvatarContent() { return; } if (!selected || !avatarCaptureRef.current) { - notifySaving(false); + suppressDiscardPrompt(false); return; } avatarCaptureRef.current @@ -111,7 +111,7 @@ function EditUserAvatarContent() { Navigation.dismissModal(); }) .catch(() => { - notifySaving(false); + suppressDiscardPrompt(false); }); }; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 3f258f2ee6b9..7d4e62a3e46f 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1,12 +1,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import DateUtils from '@libs/DateUtils'; -import { - doesMoneyRequestDraftHaveUserInput, - hasUnsavedMoneyRequestInput, - shouldShowBrokenConnectionViolation, - shouldShowBrokenConnectionViolationForMultipleTransactions, -} from '@libs/TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, shouldShowBrokenConnectionViolation, shouldShowBrokenConnectionViolationForMultipleTransactions} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -3820,33 +3815,3 @@ describe('doesMoneyRequestDraftHaveUserInput', () => { expect(doesMoneyRequestDraftHaveUserInput(transaction)).toBe(true); }); }); - -describe('hasUnsavedMoneyRequestInput', () => { - describe('create entry (any input counts)', () => { - it('is unsaved whenever the current value is not empty, even when it matches the committed value', () => { - expect(hasUnsavedMoneyRequestInput(100, 0, false, true)).toBe(true); - expect(hasUnsavedMoneyRequestInput(100, 100, false, true)).toBe(true); - expect(hasUnsavedMoneyRequestInput('1', '1', false, true)).toBe(true); - }); - - it('is unsaved for a typed "0" that normalizes to the empty value, e.g. an amount in backend units', () => { - expect(hasUnsavedMoneyRequestInput(0, 100, false, true)).toBe(true); - }); - - it('is not unsaved when the current value is empty', () => { - expect(hasUnsavedMoneyRequestInput(0, 0, true, true)).toBe(false); - expect(hasUnsavedMoneyRequestInput('', '', true, true)).toBe(false); - }); - }); - - describe('editing (only a change counts)', () => { - it('is unsaved only when the current value differs from the committed one', () => { - expect(hasUnsavedMoneyRequestInput(100, 50, false, false)).toBe(true); - expect(hasUnsavedMoneyRequestInput(100, 100, false, false)).toBe(false); - }); - - it('ignores the emptiness flag — only the current/committed comparison matters', () => { - expect(hasUnsavedMoneyRequestInput(0, 100, true, false)).toBe(true); - }); - }); -}); diff --git a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts index 73acba1b473f..62dd4d8e6a78 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts @@ -282,13 +282,13 @@ describe('useDiscardChangesConfirmation (web)', () => { it('suppresses the prompt while a save is in progress, and re-arms when notified it ended', () => { const {result} = renderDiscardHook(() => true); - act(() => result.current.notifySaving()); + act(() => result.current.suppressDiscardPrompt()); const duringSave = invokeBeforeRemove('RESET'); expect(duringSave.defaultPrevented).toBe(false); expect(mockShowConfirmModal).not.toHaveBeenCalled(); - act(() => result.current.notifySaving(false)); + act(() => result.current.suppressDiscardPrompt(false)); const afterSave = invokeBeforeRemove('RESET'); expect(afterSave.defaultPrevented).toBe(true); diff --git a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts index d6c326e97d89..d4d0ce308ca8 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts @@ -121,14 +121,14 @@ describe('useDiscardChangesConfirmation (native)', () => { expect(mockShowConfirmModal).not.toHaveBeenCalled(); }); - it('lets the back press through after notifySaving, and prompts again once the save ends', () => { + it('lets the back press through after suppressDiscardPrompt, and prompts again once the save ends', () => { const {result} = renderDiscardHook(() => true); - act(() => result.current.notifySaving()); + act(() => result.current.suppressDiscardPrompt()); expect(pressHardwareBack()).toBe(false); expect(mockShowConfirmModal).not.toHaveBeenCalled(); - act(() => result.current.notifySaving(false)); + act(() => result.current.suppressDiscardPrompt(false)); expect(pressHardwareBack()).toBe(true); expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); }); @@ -178,7 +178,6 @@ describe('useDiscardChangesConfirmation (native)', () => { }); it('stays put on cancel and prompts again on the next back press', async () => { - renderDiscardHook(() => true); const onCancel = jest.fn(); renderHook(() => useDiscardChangesConfirmation({getHasUnsavedChanges: () => true, onCancel})); From 8e245f0c2f54bf25efe9ae4de09836c8eb908ec0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 17:08:37 +0300 Subject: [PATCH 06/16] fix: let the confirmed discard replay bypass the history-restore swallow --- .../useDiscardChangesConfirmation/index.ts | 8 ++++---- src/libs/TransactionUtils/index.ts | 4 +++- .../useDiscardChangesConfirmation.test.ts | 20 +++++++++++++++++++ 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index a6a9369003ca..a094678c0d75 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -87,6 +87,10 @@ function useDiscardChangesConfirmation({ }; useBeforeRemove((e) => { + if (shouldNavigateBack.current) { + return; + } + if (restoreState.current.phase === 'restoring') { // The `history.go(1)` restoring the browser entry can re-deliver a reset for the current state; swallow it without re-blocking e.preventDefault(); @@ -107,10 +111,6 @@ function useDiscardChangesConfirmation({ return; } - if (shouldNavigateBack.current) { - return; - } - e.preventDefault(); blockedNavigationAction.current = e.data.action; if (e.data.action.type === 'RESET') { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index cf9912c7641c..57d72dd7a516 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1246,7 +1246,9 @@ function doesMoneyRequestDraftHaveUserInput(transaction: OnyxEntry) return Object.keys(getValidWaypoints(getWaypoints(transaction))).length > 0; } -/** Returns the transaction's waypoints, preferring `modifiedWaypoints` when present. */ +/** + * Return the waypoints field from the transaction, return the modifiedWaypoints if present. + */ function getWaypoints(transaction: OnyxEntry): WaypointCollection | undefined { return transaction?.modifiedWaypoints ?? transaction?.comment?.waypoints; } diff --git a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts index 62dd4d8e6a78..0cbe378c8348 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts @@ -196,6 +196,26 @@ describe('useDiscardChangesConfirmation (web)', () => { expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); }); + it('lets the confirmed re-dispatch through even before the restore echo arrives', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + // Only the go(1) has fired — restoreState is still 'restoring', its echo popstate not yet delivered + dispatchPopstate(); + + let redeliveredEvent: MockBeforeRemoveEvent | undefined; + mockNavigationDispatch.mockImplementation(() => { + redeliveredEvent = createBeforeRemoveEvent('RESET'); + mockBeforeRemoveCallback?.(redeliveredEvent); + }); + + await resolveModalWith('CONFIRM'); + + // The intentional replay must not be swallowed by the restore guard mid-restore + expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'RESET'}); + expect(redeliveredEvent?.defaultPrevented).toBe(false); + }); + it('starts a fresh flow after cancelling', async () => { renderDiscardHook(() => true); From 91abe19563adce74385a27e894dd332c41baeb72 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 17:42:49 +0300 Subject: [PATCH 07/16] fix: don't prompt discard on a clean distance edit when the waypoint baseline is missing --- src/libs/MoneyRequestUtils.ts | 9 ++++++++- src/pages/iou/request/step/IOURequestStepDistance.tsx | 9 ++++++++- tests/unit/MoneyRequestUtilsTest.ts | 4 ++++ tests/unit/hooks/useDiscardChangesConfirmation.test.ts | 2 -- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 7650fea490ef..5db26bfdcc36 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -240,7 +240,14 @@ function getWaypointsHasUnsavedChanges( currentWaypoints: WaypointCollection | undefined, isCreateEntry: boolean, ): boolean { - return isCreateEntry ? doesMoneyRequestDraftHaveUserInput(transaction) : haveWaypointAddressesChanged(committedWaypoints, currentWaypoints); + if (isCreateEntry) { + return doesMoneyRequestDraftHaveUserInput(transaction); + } + // No committed baseline yet (splits skip the backup; a normal edit's async backup may not have landed) — treat as unchanged. + if (!committedWaypoints) { + return false; + } + return haveWaypointAddressesChanged(committedWaypoints, currentWaypoints); } export { diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index bfc842e8113a..f0b217de1232 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -158,7 +158,14 @@ function IOURequestStepDistance({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, transactionBackup?.comment?.waypoints, waypoints, isCreatingNewRequest), + // Split edits skip the transaction backup, so their pre-edit route lives in `originalSplitTransactionDraft`. + getHasUnsavedChanges: () => + getWaypointsHasUnsavedChanges( + transaction, + isEditingSplit ? originalSplitTransactionDraft?.comment?.waypoints : transactionBackup?.comment?.waypoints, + waypoints, + isCreatingNewRequest, + ), }); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 758f6c1aa246..0b00d9c1ae52 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -413,5 +413,9 @@ describe('getWaypointsHasUnsavedChanges (distance map)', () => { it('does not flag when the waypoints are unchanged', () => { expect(getWaypointsHasUnsavedChanges(undefined, waypointsA, waypointsA, false)).toBe(false); }); + + it('does not flag when the committed baseline is missing', () => { + expect(getWaypointsHasUnsavedChanges(undefined, undefined, waypointsA, false)).toBe(false); + }); }); }); diff --git a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts index 0cbe378c8348..9714ed9e4b80 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts @@ -200,7 +200,6 @@ describe('useDiscardChangesConfirmation (web)', () => { renderDiscardHook(() => true); invokeBeforeRemove('RESET'); - // Only the go(1) has fired — restoreState is still 'restoring', its echo popstate not yet delivered dispatchPopstate(); let redeliveredEvent: MockBeforeRemoveEvent | undefined; @@ -211,7 +210,6 @@ describe('useDiscardChangesConfirmation (web)', () => { await resolveModalWith('CONFIRM'); - // The intentional replay must not be swallowed by the restore guard mid-restore expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'RESET'}); expect(redeliveredEvent?.defaultPrevented).toBe(false); }); From 220acc5912c62ca3cf1489c49ace75eb8ce4396e Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 18:53:28 +0300 Subject: [PATCH 08/16] fix: suppress the discard prompt on distance edit-save navigation --- src/pages/iou/request/step/IOURequestStepDistance.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index f0b217de1232..85401a52940b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -314,6 +314,8 @@ function IOURequestStepDistance({ }, [backTo]); const navigateBackAfterSave = useCallback(() => { + // Suppress the discard prompt — otherwise `beforeRemove` treats this post-save navigation as an abandoned dirty edit. + suppressDiscardPrompt(); // When editing an individual split, the previous RHP screen is the edit-split page the user // wants to return to — `closeRHPFlow` would tear it down too. Use `goBack` so the RHP stays // open at the edit-split page. @@ -322,7 +324,7 @@ function IOURequestStepDistance({ return; } Navigation.closeRHPFlow(); - }, [isEditingSplit, backTo]); + }, [isEditingSplit, backTo, suppressDiscardPrompt]); // In the edit flow this page is rendered inside an OnyxTabNavigator. The header back honors an // explicit `backTo` (e.g. the edit-split page) and otherwise leaves the whole flow. From 38c5cea11da4ad7485545a896e649942a11a27d4 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 19:09:27 +0300 Subject: [PATCH 09/16] fix: flag manual distance edits in the distance discard check --- .../request/step/IOURequestStepDistance.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 85401a52940b..0a8d39f7989e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -157,17 +157,6 @@ function IOURequestStepDistance({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); - const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ - // Split edits skip the transaction backup, so their pre-edit route lives in `originalSplitTransactionDraft`. - getHasUnsavedChanges: () => - getWaypointsHasUnsavedChanges( - transaction, - isEditingSplit ? originalSplitTransactionDraft?.comment?.waypoints : transactionBackup?.comment?.waypoints, - waypoints, - isCreatingNewRequest, - ), - }); - const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); // Manual distance editing state @@ -196,6 +185,17 @@ function IOURequestStepDistance({ [distanceInMeters, distanceUnit], ); + const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => { + // The Manual tab holds the typed distance in `manualNumberFormRef` until Save — check it separately from waypoints, matching the save path. + const typedDistance = manualNumberFormRef.current?.getNumber(); + const manualDistanceChanged = !!typedDistance && roundToTwoDecimalPlaces(parseFloat(typedDistance)) !== currentDistance; + // Split edits skip the transaction backup, so their pre-edit route lives in `originalSplitTransactionDraft`. + const committedWaypoints = isEditingSplit ? originalSplitTransactionDraft?.comment?.waypoints : transactionBackup?.comment?.waypoints; + return manualDistanceChanged || getWaypointsHasUnsavedChanges(transaction, committedWaypoints, waypoints, isCreatingNewRequest); + }, + }); + // Track whether the user has typed in the manual tab so route re-fetches don't clobber in-progress // input. Editing waypoints clears this (in the effect below) — a recalculated route supersedes a // manual value the same way it would on a fresh map expense (GH #90083). From 4120f352cac66079c94d5364fad78974c8c9d280 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 19:30:42 +0300 Subject: [PATCH 10/16] fix: treat a cleared manual distance as a discard-worthy edit --- src/pages/iou/request/step/IOURequestStepDistance.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 0a8d39f7989e..333647d83f22 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -187,9 +187,11 @@ function IOURequestStepDistance({ const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { - // The Manual tab holds the typed distance in `manualNumberFormRef` until Save — check it separately from waypoints, matching the save path. - const typedDistance = manualNumberFormRef.current?.getNumber(); - const manualDistanceChanged = !!typedDistance && roundToTwoDecimalPlaces(parseFloat(typedDistance)) !== currentDistance; + // Manual distance sits in `manualNumberFormRef` until Save — gate on the mounted ref so a cleared (empty) value still counts as dirty against a committed distance. + const manualForm = manualNumberFormRef.current; + const typedDistance = manualForm?.getNumber(); + const typedManualDistance = typedDistance ? roundToTwoDecimalPlaces(parseFloat(typedDistance)) : undefined; + const manualDistanceChanged = !!manualForm && typedManualDistance !== currentDistance; // Split edits skip the transaction backup, so their pre-edit route lives in `originalSplitTransactionDraft`. const committedWaypoints = isEditingSplit ? originalSplitTransactionDraft?.comment?.waypoints : transactionBackup?.comment?.waypoints; return manualDistanceChanged || getWaypointsHasUnsavedChanges(transaction, committedWaypoints, waypoints, isCreatingNewRequest); From 2c2beeadbd8d1f00f9c4a6abeff5a791da512515 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Wed, 1 Jul 2026 21:34:29 +0300 Subject: [PATCH 11/16] refactor: make IOURequestStepDistanceMap honestly create-only --- .../step/IOURequestStepDistanceMap.tsx | 152 +++--------------- 1 file changed, 20 insertions(+), 132 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index 538596df54b0..6f53abcdf186 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -1,5 +1,4 @@ import {deepEqual} from 'fast-equals'; -import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; @@ -9,9 +8,7 @@ import DistanceRequestRenderItem from '@components/DistanceRequest/DistanceReque import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; -import useDelegateAccountID from '@hooks/useDelegateAccountID'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; -import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useFetchRoute from '@hooks/useFetchRoute'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -26,17 +23,14 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; import useWaypointItems from '@hooks/useWaypointItems'; -import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; -import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; import {init, stop} from '@libs/actions/MapboxToken'; import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypointsUtil} from '@libs/actions/Transaction'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import {getWaypointsHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {getRateID, getRequestType} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -49,7 +43,6 @@ import type TransactionStateType from '@src/types/utils/TransactionStateType'; import DistanceMapTabContent from './DistanceMapTabContent'; import useDistanceNavigation from './IOURequestStepDistance/hooks/useDistanceNavigation'; import useDistanceRequestData from './IOURequestStepDistance/hooks/useDistanceRequestData'; -import useDistanceTransactionBackup from './IOURequestStepDistance/hooks/useDistanceTransactionBackup'; import useWaypointValidation, {isWaypointEmpty} from './IOURequestStepDistance/hooks/useWaypointValidation'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; @@ -75,26 +68,16 @@ function IOURequestStepDistanceMap({ const {isBetaEnabled} = usePermissions(); const {policyForMovingExpenses} = usePolicyForMovingExpenses(); const isArchived = useReportIsArchived(report?.reportID); - const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [parentReportNextStep] = useOnyx(`${ONYXKEYS.COLLECTION.NEXT_STEP}${getNonEmptyStringOnyxID(report?.parentReportID)}`); - const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`); - const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`); const selfDMReport = useSelfDMReport(); const policy = usePolicy(report?.policyID); - const distanceOriginalPolicy = useDistanceRateOriginalPolicy(transaction?.comment?.customUnit?.customUnitRateID); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy?.id}`); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); const personalPolicy = usePersonalPolicy(); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const defaultExpensePolicy = useDefaultExpensePolicy(); const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [optimisticWaypoints, setOptimisticWaypoints] = useState(null); const [betas] = useOnyx(ONYXKEYS.BETAS); - const isEditing = action === CONST.IOU.ACTION.EDIT; - const isEditingSplit = (iouType === CONST.IOU.TYPE.SPLIT || iouType === CONST.IOU.TYPE.SPLIT_EXPENSE) && isEditing; - const currentTransaction = isEditingSplit && !isEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction; - const transactionWaypoints = currentTransaction?.comment?.waypoints; + const transactionWaypoints = transaction?.comment?.waypoints; const areTransactionWaypointsEmpty = !transactionWaypoints || Object.values(transactionWaypoints).every((w) => isEmptyObject(w)); const waypoints = useMemo(() => { @@ -110,38 +93,27 @@ function IOURequestStepDistanceMap({ }, [optimisticWaypoints, transactionWaypoints, areTransactionWaypointsEmpty]); const reportAttributesDerived = useReportAttributes(); - let transactionState: TransactionStateType = CONST.TRANSACTION.STATE.CURRENT; - if (isEditingSplit) { - transactionState = CONST.TRANSACTION.STATE.SPLIT_DRAFT; - } else if (shouldUseTransactionDraft(action)) { - transactionState = CONST.TRANSACTION.STATE.DRAFT; - } - const backupWaypoints = transactionBackup?.pendingFields?.waypoints ? transactionBackup?.comment?.waypoints : undefined; - // When online, fetch the backup route to ensure the map is populated even if the user does not save the transaction. - // Fetch the backup route first to ensure the backup transaction map is updated before the main transaction map. - // This prevents a scenario where the main map loads, the user dismisses the map editor, and the backup map has not yet loaded due to delay. - useFetchRoute(transactionBackup, backupWaypoints, action, CONST.TRANSACTION.STATE.BACKUP); - const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(currentTransaction, waypoints, action, transactionState); + const transactionState: TransactionStateType = shouldUseTransactionDraft(action) ? CONST.TRANSACTION.STATE.DRAFT : CONST.TRANSACTION.STATE.CURRENT; + const {shouldFetchRoute, validatedWaypoints} = useFetchRoute(transaction, waypoints, action, transactionState); const previousWaypoints = usePrevious(waypoints); const numberOfWaypoints = Object.keys(waypoints).length; const numberOfPreviousWaypoints = Object.keys(previousWaypoints).length; const scrollViewRef = useRef(null); - const isLoadingRoute = currentTransaction?.comment?.isLoading ?? false; - const isLoading = currentTransaction?.isLoading ?? false; + const isLoadingRoute = transaction?.comment?.isLoading ?? false; + const isLoading = transaction?.isLoading ?? false; const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; - const hasRouteError = !!currentTransaction?.errorFields?.route; + const hasRouteError = !!transaction?.errorFields?.route; const [shouldShowAtLeastTwoDifferentWaypointsError, setShouldShowAtLeastTwoDifferentWaypointsError] = useState(false); const {nonEmptyWaypointsCount, isWaypointsNullIslandError, duplicateWaypointsError, atLeastTwoDifferentWaypointsError} = useWaypointValidation({waypoints, validatedWaypoints}); - const isCreatingNewRequest = !(backTo || isEditing); const [recentWaypoints, {status: recentWaypointsStatus}] = useOnyx(ONYXKEYS.NVP_RECENT_WAYPOINTS); - const iouRequestType = getRequestType(currentTransaction); - const customUnitRateID = getRateID(currentTransaction); + const iouRequestType = getRequestType(transaction); + const customUnitRateID = getRateID(transaction); - const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); + const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, transactionBackup?.comment?.waypoints, waypoints, isCreatingNewRequest), + getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, undefined, waypoints, true), }); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); @@ -149,7 +121,6 @@ function IOURequestStepDistanceMap({ const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const delegateAccountID = useDelegateAccountID(); const setDistanceRequestData = useDistanceRequestData({ policy, @@ -170,7 +141,7 @@ function IOURequestStepDistanceMap({ return iouType !== CONST.IOU.TYPE.SPLIT && !isArchived && !(isPolicyExpenseChatUtil(report) && ((policy?.requiresCategory ?? false) || (policy?.requiresTag ?? false))); }, [report, skipConfirmation, policy?.requiresCategory, policy?.requiresTag, isArchived, iouType]); - let buttonText = !isCreatingNewRequest ? translate('common.save') : translate('common.next'); + let buttonText = translate('common.next'); if (shouldSkipConfirmation) { if (iouType === CONST.IOU.TYPE.SPLIT) { buttonText = translate('iou.split'); @@ -208,17 +179,6 @@ function IOURequestStepDistanceMap({ setShouldShowAtLeastTwoDifferentWaypointsError(false); }, [atLeastTwoDifferentWaypointsError, duplicateWaypointsError, hasRouteError, isLoading, isLoadingRoute, nonEmptyWaypointsCount, transaction]); - const transactionWasSaved = useRef(false); - useDistanceTransactionBackup({ - transaction, - isCreatingNewRequest, - isEditingSplit: false, - isDraft: shouldUseTransactionDraft(action), - introSelected, - betas, - transactionWasSavedRef: transactionWasSaved, - }); - const navigateBack = useCallback(() => { Navigation.goBack(backTo); }, [backTo]); @@ -269,7 +229,7 @@ function IOURequestStepDistanceMap({ const getError = useCallback(() => { // Get route error if available else show the invalid number of waypoints error. if (hasRouteError) { - return getLatestErrorField(currentTransaction, 'route'); + return getLatestErrorField(transaction, 'route'); } if (isWaypointsNullIslandError) { return {isWaypointsNullIslandError: `${translate('common.please')} ${translate('common.fixTheErrors')} ${translate('common.inTheFormBeforeContinuing')}.`} as Errors; @@ -281,7 +241,7 @@ function IOURequestStepDistanceMap({ return {atLeastTwoDifferentWaypointsError: translate('iou.error.atLeastTwoDifferentWaypoints')} as Errors; } return {}; - }, [hasRouteError, isWaypointsNullIslandError, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, currentTransaction, translate]); + }, [hasRouteError, isWaypointsNullIslandError, duplicateWaypointsError, atLeastTwoDifferentWaypointsError, transaction, translate]); type DataParams = { data: string[]; @@ -306,98 +266,26 @@ function IOURequestStepDistanceMap({ } setOptimisticWaypoints(newWaypoints); - const shouldPassSplitDraft = isEditingSplit && !isEmpty(splitDraftTransaction); Promise.all([ - removeWaypoint(currentTransaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), shouldPassSplitDraft ? splitDraftTransaction : undefined), + removeWaypoint(transaction, emptyWaypointIndex.toString(), shouldUseTransactionDraft(action), undefined), updateWaypointsUtil(transactionID, newWaypoints, transactionState), ]).then(() => { setOptimisticWaypoints(null); }); }, - [waypointItems, isEditingSplit, splitDraftTransaction, currentTransaction, action, transactionID, transactionState, getWaypoint, waypoints], + [waypointItems, transaction, action, transactionID, transactionState, getWaypoint, waypoints], ); const submitWaypoints = useCallback(() => { // If there is any error or loading state, don't let user go to next page. - if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || (!isEditing && isLoading)) { + if (duplicateWaypointsError || atLeastTwoDifferentWaypointsError || hasRouteError || isLoadingRoute || isLoading) { setShouldShowAtLeastTwoDifferentWaypointsError(true); return; } - if (!isCreatingNewRequest && !isEditing) { - transactionWasSaved.current = true; - } - if (isEditing) { - // In the split flow, when editing we use SPLIT_TRANSACTION_DRAFT to save draft value - if (isEditingSplit && transaction) { - setDraftSplitTransaction(transaction.transactionID, splitDraftTransaction, {waypoints}, policy); - navigateBack(); - return; - } - - // If nothing was changed, simply go to transaction thread - // We compare only addresses because numbers are rounded while backup - const hasRouteChanged = !deepEqual(transactionBackup?.routes, transaction?.routes); - if (!haveWaypointAddressesChanged(transactionBackup?.comment?.waypoints, waypoints)) { - navigateBack(); - return; - } - if (transaction?.transactionID && report?.reportID) { - updateMoneyRequestDistance({ - transaction, - transactionThreadReport: report, - parentReport, - waypoints, - recentWaypoints, - ...(hasRouteChanged ? {routes: transaction?.routes} : {}), - policy, - distanceOriginalPolicy, - policyTagList: policyTags, - policyCategories, - transactionBackup, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - parentReportNextStep, - delegateAccountID, - }); - } - transactionWasSaved.current = true; - navigateBack(); - return; - } - suppressDiscardPrompt(); navigateToNextStep(); - }, [ - duplicateWaypointsError, - atLeastTwoDifferentWaypointsError, - hasRouteError, - isLoadingRoute, - isEditing, - isLoading, - isCreatingNewRequest, - navigateToNextStep, - suppressDiscardPrompt, - isEditingSplit, - transaction, - transactionBackup, - waypoints, - report, - navigateBack, - splitDraftTransaction, - policy, - parentReport, - policyTags, - policyCategories, - currentUserAccountIDParam, - currentUserEmailParam, - isASAPSubmitBetaEnabled, - parentReportNextStep, - recentWaypoints, - distanceOriginalPolicy, - delegateAccountID, - ]); + }, [duplicateWaypointsError, atLeastTwoDifferentWaypointsError, hasRouteError, isLoadingRoute, isLoading, suppressDiscardPrompt, navigateToNextStep]); const renderItem = useCallback( ({item, drag, isActive, getIndex}: RenderItemParams) => { @@ -431,8 +319,8 @@ function IOURequestStepDistanceMap({ headerTitle={translate('common.distance')} onBackButtonPress={navigateBack} testID="IOURequestStepDistanceMap" - shouldShowNotFoundPage={(isEditing && !currentTransaction?.comment?.waypoints) || shouldShowNotFoundPage} - shouldShowWrapper={!isCreatingNewRequest} + shouldShowNotFoundPage={shouldShowNotFoundPage} + shouldShowWrapper={false} > Date: Wed, 1 Jul 2026 21:51:55 +0300 Subject: [PATCH 12/16] test: use a cspell-clean waypoint address in MoneyRequestUtilsTest --- tests/unit/MoneyRequestUtilsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 0b00d9c1ae52..19ed992ceba7 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -388,7 +388,7 @@ describe('getWaypointsHasUnsavedChanges (distance map)', () => { }; const waypointsB: WaypointCollection = { waypoint0: {address: 'Q Mall, Doha', lat: 25.3272762, lng: 51.4659325}, - waypoint1: {address: 'West Walk - Salwa Road, Doha', lat: 25.2510416, lng: 51.4699357}, + waypoint1: {address: 'West Bay, Doha', lat: 25.2510416, lng: 51.4699357}, }; describe('create entry (any entered waypoint counts)', () => { From ed7742702c49ea8c5fa5b9fa84cb532fb71a2e65 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Fri, 10 Jul 2026 19:00:45 +0300 Subject: [PATCH 13/16] refactor: use the create-presence check directly in the create-only distance-map screen --- src/pages/iou/request/step/IOURequestStepDistanceMap.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index c408602c36de..61f22c109c77 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -23,10 +23,9 @@ import {init, stop} from '@libs/actions/MapboxToken'; import {openDraftDistanceExpense, removeWaypoint, updateWaypoints as updateWaypointsUtil} from '@libs/actions/Transaction'; import {getLatestErrorField} from '@libs/ErrorUtils'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; -import {getWaypointsHasUnsavedChanges} from '@libs/MoneyRequestUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {getRateID, getRequestType} from '@libs/TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, getRateID, getRequestType} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -120,7 +119,7 @@ function IOURequestStepDistanceMap({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => getWaypointsHasUnsavedChanges(transaction, undefined, waypoints, true), + getHasUnsavedChanges: () => doesMoneyRequestDraftHaveUserInput(transaction), }); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); From 5a0e627ef387fa7248c1c9eb1f849b0c61e4ae98 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 11 Jul 2026 11:31:40 +0300 Subject: [PATCH 14/16] fix: flag a cleared amount field as dirty when the create draft already holds an amount --- src/libs/MoneyRequestUtils.ts | 2 +- tests/unit/MoneyRequestUtilsTest.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index d051105af225..9fb4c2bb8b88 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -224,7 +224,7 @@ type AmountHasUnsavedChangesParams = { function getAmountHasUnsavedChanges({typedAmount, committedAmount, isCreateEntry, selectedCurrency, originalCurrency}: AmountHasUnsavedChangesParams): boolean { const currencyChanged = selectedCurrency !== originalCurrency; if (isCreateEntry) { - return typedAmount !== '' || currencyChanged; + return typedAmount !== '' || committedAmount !== 0 || currencyChanged; } const typedAmountInBackendUnits = typedAmount ? convertToBackendAmount(Number.parseFloat(typedAmount)) : 0; return typedAmountInBackendUnits !== committedAmount || currencyChanged; diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index fbecda4ffb68..cab6f9119a45 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -363,6 +363,17 @@ describe('getAmountHasUnsavedChanges', () => { ).toBe(false); }); + it('flags a cleared field when the draft already holds an amount (revisited after Next)', () => { + expect( + getAmountHasUnsavedChanges({ + ...sameCurrency, + typedAmount: '', + committedAmount: 100, + isCreateEntry: true, + }), + ).toBe(true); + }); + it('flags a currency change even with no amount entered', () => { expect( getAmountHasUnsavedChanges({ From f91d1550950a2c98abdceae53d6ca3edc5d8d8d0 Mon Sep 17 00:00:00 2001 From: TaduJR Date: Sat, 11 Jul 2026 11:48:44 +0300 Subject: [PATCH 15/16] fix: flag cleared create fields as dirty when the draft already holds a value --- src/libs/MoneyRequestUtils.ts | 2 +- tests/unit/MoneyRequestUtilsTest.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 9fb4c2bb8b88..ab932ff36b9f 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -232,7 +232,7 @@ function getAmountHasUnsavedChanges({typedAmount, committedAmount, isCreateEntry /** Whether a raw-string money-request step (hours, manual distance) has unsaved input. */ function getStringFieldHasUnsavedChanges(typedValue: string, committedValue: string, isCreateEntry: boolean): boolean { - return isCreateEntry ? typedValue !== '' : typedValue !== committedValue; + return isCreateEntry ? typedValue !== '' || committedValue !== '' : typedValue !== committedValue; } /** Whether the distance (map) step has unsaved waypoints. */ diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index cab6f9119a45..520c7e71488f 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -442,6 +442,10 @@ describe('getStringFieldHasUnsavedChanges (hours, manual distance)', () => { it('does not flag an empty field', () => { expect(getStringFieldHasUnsavedChanges('', '', true)).toBe(false); }); + + it('flags a cleared field when the draft already holds a value (revisited after Next)', () => { + expect(getStringFieldHasUnsavedChanges('', '5', true)).toBe(true); + }); }); describe('editing (only a change counts)', () => { From 205a17220a390e873665d7793b9244138c06b03c Mon Sep 17 00:00:00 2001 From: TaduJR Date: Mon, 13 Jul 2026 14:34:46 +0300 Subject: [PATCH 16/16] style: make the money-request predicate JSDocs multiline --- src/libs/MoneyRequestUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index ab932ff36b9f..32a2a629b03a 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -230,12 +230,16 @@ function getAmountHasUnsavedChanges({typedAmount, committedAmount, isCreateEntry return typedAmountInBackendUnits !== committedAmount || currencyChanged; } -/** Whether a raw-string money-request step (hours, manual distance) has unsaved input. */ +/** + * Whether a raw-string money-request step (hours, manual distance) has unsaved input. + */ function getStringFieldHasUnsavedChanges(typedValue: string, committedValue: string, isCreateEntry: boolean): boolean { return isCreateEntry ? typedValue !== '' || committedValue !== '' : typedValue !== committedValue; } -/** Whether the distance (map) step has unsaved waypoints. */ +/** + * Whether the distance (map) step has unsaved waypoints. + */ function getWaypointsHasUnsavedChanges( transaction: OnyxEntry, committedWaypoints: WaypointCollection | undefined,