From c19d3a4cbc4f00a3a295d5cd0821653cc9d6c9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 7 Apr 2026 18:19:28 +0200 Subject: [PATCH 01/10] fix: remove isEnabled from discard confirmation --- src/hooks/useBeforeRemove.tsx | 7 ++-- .../index.native.ts | 7 ++-- .../useDiscardChangesConfirmation/index.ts | 33 ++++--------------- .../useDiscardChangesConfirmation/types.ts | 1 - .../AuthorizeTransactionPage/index.tsx | 6 ++-- .../step/IOURequestStepDistanceOdometer.tsx | 20 +++++------ 6 files changed, 24 insertions(+), 50 deletions(-) diff --git a/src/hooks/useBeforeRemove.tsx b/src/hooks/useBeforeRemove.tsx index 908bb659960c..835d5a30babe 100644 --- a/src/hooks/useBeforeRemove.tsx +++ b/src/hooks/useBeforeRemove.tsx @@ -3,16 +3,13 @@ import type {EventListenerCallback, EventMapCore, NavigationState} from '@react- import {useEffect} from 'react'; // beforeRemove have some limitations. When the react-navigation is upgraded to 7.x, update this to use usePreventRemove hook. -const useBeforeRemove = (onBeforeRemove: EventListenerCallback, 'beforeRemove'>, isEnabled = true) => { +const useBeforeRemove = (onBeforeRemove: EventListenerCallback, 'beforeRemove'>) => { const navigation = useNavigation(); useEffect(() => { - if (!isEnabled) { - return undefined; - } const unsubscribe = navigation.addListener('beforeRemove', onBeforeRemove); return unsubscribe; - }, [navigation, onBeforeRemove, isEnabled]); + }, [navigation, onBeforeRemove]); }; export default useBeforeRemove; diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 4a14cb0b66a7..0b6739f4b097 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -1,5 +1,5 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useIsFocused, usePreventRemove} from '@react-navigation/native'; +import {usePreventRemove} from '@react-navigation/native'; import {useCallback, useRef, useState} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -7,14 +7,13 @@ import useLocalize from '@hooks/useLocalize'; import navigationRef from '@libs/Navigation/navigationRef'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange, isEnabled = true}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange}: UseDiscardChangesConfirmationOptions) { const {translate} = useLocalize(); - const isFocused = useIsFocused(); const {showConfirmModal} = useConfirmModal(); const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); const blockedNavigationAction = useRef(undefined); - const shouldPrevent = isEnabled && isFocused && !shouldAllowNavigation; + const shouldPrevent = !shouldAllowNavigation; usePreventRemove( shouldPrevent, diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 5f0f7b035506..913bb7375d51 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -1,5 +1,5 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useIsFocused, useNavigation} from '@react-navigation/native'; +import {useNavigation} from '@react-navigation/native'; import {useCallback, useEffect, useRef} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useBeforeRemove from '@hooks/useBeforeRemove'; @@ -12,14 +12,12 @@ import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNa import type {RootNavigatorParamList} from '@libs/Navigation/types'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, isEnabled = true}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange}: UseDiscardChangesConfirmationOptions) { const navigation = useNavigation>(); - const isFocused = useIsFocused(); const {translate} = useLocalize(); - const {showConfirmModal, closeModal} = useConfirmModal(); + const {showConfirmModal} = useConfirmModal(); const blockedNavigationAction = useRef(undefined); const shouldNavigateBack = useRef(false); - const isDiscardModalOpenRef = useRef(false); const navigateBack = useCallback(() => { if (blockedNavigationAction.current) { @@ -34,7 +32,6 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi const showDiscardModal = useCallback(() => { onVisibilityChange?.(true); - isDiscardModalOpenRef.current = true; showConfirmModal({ title: translate('discardChangesConfirmation.title'), prompt: translate('discardChangesConfirmation.body'), @@ -43,7 +40,6 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi cancelText: translate('common.cancel'), shouldIgnoreBackHandlerDuringTransition: true, }).then((result) => { - isDiscardModalOpenRef.current = false; onVisibilityChange?.(false); if (result.action === ModalActions.CONFIRM) { setNavigationActionToMicrotaskQueue(navigateBack); @@ -58,7 +54,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi useBeforeRemove( useCallback( (e) => { - if (!isEnabled || !isFocused || !getHasUnsavedChanges() || shouldNavigateBack.current) { + if (!getHasUnsavedChanges() || shouldNavigateBack.current) { return; } @@ -66,9 +62,8 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi blockedNavigationAction.current = e.data.action; navigateAfterInteraction(showDiscardModal); }, - [getHasUnsavedChanges, isFocused, isEnabled, showDiscardModal], + [getHasUnsavedChanges, showDiscardModal], ), - isEnabled && isFocused, ); /** @@ -77,9 +72,6 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi * So we need to go forward to get back to the current page. */ useEffect(() => { - if (!isEnabled || !isFocused) { - return undefined; - } const unsubscribe = navigation.addListener('transitionStart', ({data: {closing}}) => { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (!getHasUnsavedChanges()) { @@ -95,20 +87,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi }); return unsubscribe; - }, [navigation, getHasUnsavedChanges, isFocused, isEnabled, showDiscardModal]); - - /** - * When the screen loses focus (or is disabled) while the discard modal is open, - * close the modal and reset refs so we don't leave the modal visible or stale state. - */ - useEffect(() => { - if ((isFocused && isEnabled) || !isDiscardModalOpenRef.current) { - return; - } - closeModal(); - blockedNavigationAction.current = undefined; - shouldNavigateBack.current = false; - }, [isFocused, isEnabled, closeModal]); + }, [navigation, getHasUnsavedChanges, showDiscardModal]); } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index 6ce984e471f4..383335dd7e91 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -2,7 +2,6 @@ type UseDiscardChangesConfirmationOptions = { getHasUnsavedChanges: () => boolean; onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; - isEnabled?: boolean; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx index c877b622ebef..b6b9042fe3c0 100644 --- a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx +++ b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx @@ -78,16 +78,16 @@ function MultifactorAuthenticationScenarioAuthorizeTransactionPage({route}: Mult const onBeforeRemove: Parameters[0] = useCallback( (e) => { - if (allowNavigatingAwayRef.current) { + if (allowNavigatingAwayRef.current || !transaction || !!denyOutcomeScreen) { return; } e.preventDefault(); showConfirmModal(); }, - [showConfirmModal], + [showConfirmModal, transaction, denyOutcomeScreen], ); - useBeforeRemove(onBeforeRemove, !!transaction && !denyOutcomeScreen); + useBeforeRemove(onBeforeRemove); const onApproveTransaction = () => { addBreadcrumb('Approve tapped', {transactionID}); diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index e2cf38c6e3de..5445b77b9e1a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -94,7 +94,8 @@ function IOURequestStepDistanceOdometer({ const initialStartImageRef = useRef(undefined); const initialEndImageRef = useRef(undefined); const prevSelectedTabRef = useRef(undefined); - const transactionWasSaved = useRef(false); + const didSaveEditingConfirmationRef = useRef(false); + const shouldBypassDiscardConfirmationRef = useRef(false); const backupHandledManually = useRef(false); const isArchived = useReportIsArchived(report?.reportID); @@ -131,10 +132,10 @@ function IOURequestStepDistanceOdometer({ const currentTransaction = isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction; const isEditingConfirmation = routeName !== SCREENS.MONEY_REQUEST.DISTANCE_CREATE && !isEditing; const isCreatingNewRequest = !isEditingConfirmation && !isEditing; + const shouldEnableDiscardConfirmation = !isEditingConfirmation && !isEditing; const isTransactionDraft = shouldUseTransactionDraft(action, iouType); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const [shouldEnableDiscardConfirmation, setShouldEnableDiscardConfirmation] = useState(!isEditingConfirmation && !isEditing); const shouldUseDefaultExpensePolicy = useMemo( () => shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd), @@ -149,10 +150,6 @@ function IOURequestStepDistanceOdometer({ const confirmationRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID, backToReport); - useEffect(() => { - setShouldEnableDiscardConfirmation(!isEditingConfirmation && !isEditing); - }, [isEditing, isEditingConfirmation]); - // Get odometer images from transaction (only for display, not for initialization) const odometerStartImage = transaction?.comment?.odometerStartImage; const odometerEndImage = transaction?.comment?.odometerEndImage; @@ -243,7 +240,7 @@ function IOURequestStepDistanceOdometer({ if (backupHandledManually.current) { return; } - if (transactionWasSaved.current) { + if (didSaveEditingConfirmationRef.current) { removeBackupTransactionWithImageCleanup(transactionID, isTransactionDraft); return; } @@ -455,13 +452,14 @@ function IOURequestStepDistanceOdometer({ } if (isEditingConfirmation) { - transactionWasSaved.current = true; + didSaveEditingConfirmationRef.current = true; Navigation.goBack(confirmationRoute); return; } if (shouldSkipConfirmation) { - setShouldEnableDiscardConfirmation(false); + // Skip-confirmation submit navigates away and should never be blocked by discard modal. + shouldBypassDiscardConfirmationRef.current = true; } handleMoneyRequestStepDistanceNavigation({ @@ -544,8 +542,10 @@ function IOURequestStepDistanceOdometer({ }; useDiscardChangesConfirmation({ - isEnabled: shouldEnableDiscardConfirmation, getHasUnsavedChanges: () => { + if (!shouldEnableDiscardConfirmation || shouldBypassDiscardConfirmationRef.current) { + return false; + } const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; const hasImageChanges = transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current; return hasReadingChanges || hasImageChanges; From 5d6eecd8e486a52122c90567c58b464424a8a992 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 8 Apr 2026 18:40:38 +0200 Subject: [PATCH 02/10] feat: show discard changes modal when leaving distance confirmation page with unsaved changes --- .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index fbe7db8e77d5..fcdf504ac716 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -1,3 +1,4 @@ +import {useIsFocused} from '@react-navigation/native'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -133,10 +134,11 @@ function IOURequestStepDistanceOdometer({ const currentTransaction = isEditingSplit && !lodashIsEmpty(splitDraftTransaction) ? splitDraftTransaction : transaction; const isEditingConfirmation = routeName !== SCREENS.MONEY_REQUEST.DISTANCE_CREATE && !isEditing; const isCreatingNewRequest = !isEditingConfirmation && !isEditing; - const shouldEnableDiscardConfirmation = !isEditingConfirmation && !isEditing; const isTransactionDraft = shouldUseTransactionDraft(action, iouType); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; + const shouldEnableDiscardConfirmation = !isEditing; + const isFocused = useIsFocused(); const shouldUseDefaultExpensePolicy = useMemo( () => shouldUseDefaultExpensePolicyUtil(iouType, defaultExpensePolicy, amountOwed, userBillingGracePeriodEnds, ownerBillingGracePeriodEnd), @@ -544,7 +546,7 @@ function IOURequestStepDistanceOdometer({ useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { - if (!shouldEnableDiscardConfirmation || shouldBypassDiscardConfirmationRef.current) { + if (!isFocused || !shouldEnableDiscardConfirmation || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current) { return false; } const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; From 0763acd9edfc9d465468792bab7f7825830746e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 8 Apr 2026 19:56:01 +0200 Subject: [PATCH 03/10] refactor: remove redundant useCallback from beforeRemove handlers --- .../useDiscardChangesConfirmation/index.ts | 22 +++++++------------ .../AuthorizeTransactionPage/index.tsx | 17 ++++++-------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 913bb7375d51..2d7b5fa09574 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -51,20 +51,14 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi }); }, [showConfirmModal, translate, navigateBack, onCancel, onVisibilityChange]); - useBeforeRemove( - useCallback( - (e) => { - if (!getHasUnsavedChanges() || shouldNavigateBack.current) { - return; - } - - e.preventDefault(); - blockedNavigationAction.current = e.data.action; - navigateAfterInteraction(showDiscardModal); - }, - [getHasUnsavedChanges, showDiscardModal], - ), - ); + useBeforeRemove((e) => { + if (!getHasUnsavedChanges() || shouldNavigateBack.current) { + return; + } + e.preventDefault(); + blockedNavigationAction.current = e.data.action; + navigateAfterInteraction(showDiscardModal); + }); /** * We cannot programmatically stop the browser's back navigation like react-navigation's beforeRemove. diff --git a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx index b6b9042fe3c0..a6ec0b900848 100644 --- a/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx +++ b/src/pages/MultifactorAuthentication/AuthorizeTransactionPage/index.tsx @@ -76,16 +76,13 @@ function MultifactorAuthenticationScenarioAuthorizeTransactionPage({route}: Mult setConfirmModalVisibility(false); }; - const onBeforeRemove: Parameters[0] = useCallback( - (e) => { - if (allowNavigatingAwayRef.current || !transaction || !!denyOutcomeScreen) { - return; - } - e.preventDefault(); - showConfirmModal(); - }, - [showConfirmModal, transaction, denyOutcomeScreen], - ); + const onBeforeRemove: Parameters[0] = (e) => { + if (allowNavigatingAwayRef.current || !transaction || !!denyOutcomeScreen) { + return; + } + e.preventDefault(); + showConfirmModal(); + }; useBeforeRemove(onBeforeRemove); From c77709c9bf25f021c550ef7abfdd0e2ed042a5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 9 Apr 2026 22:56:54 +0200 Subject: [PATCH 04/10] fix: add onConfirm prop to useDiscardChangesConfirmation hook to allow proper cleanup of backup transaction --- .../index.native.ts | 22 ++++++++++++------- .../useDiscardChangesConfirmation/index.ts | 7 ++++-- .../useDiscardChangesConfirmation/types.ts | 1 + .../step/IOURequestStepDistanceOdometer.tsx | 13 ++++++----- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 0b6739f4b097..361b5dc5c372 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -7,7 +7,7 @@ import useLocalize from '@hooks/useLocalize'; import navigationRef from '@libs/Navigation/navigationRef'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); @@ -37,16 +37,22 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange if (result.action !== ModalActions.CONFIRM) { return; } - setShouldAllowNavigation(true); - if (blockedNavigationAction.current) { - navigationRef.current?.dispatch(blockedNavigationAction.current); - blockedNavigationAction.current = undefined; - } else { - navigationRef.current?.goBack(); + const confirmNavigation = () => { + setShouldAllowNavigation(true); + if (blockedNavigationAction.current) { + navigationRef.current?.dispatch(blockedNavigationAction.current); + blockedNavigationAction.current = undefined; + } else { + navigationRef.current?.goBack(); + } + }; + if (onConfirm) { + onConfirm?.(); } + confirmNavigation(); }); }, - [getHasUnsavedChanges, onVisibilityChange, showConfirmModal, translate], + [getHasUnsavedChanges, onVisibilityChange, onConfirm, showConfirmModal, translate], ), ); } diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 2d7b5fa09574..768cf27795bc 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -12,7 +12,7 @@ import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNa import type {RootNavigatorParamList} from '@libs/Navigation/types'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { const navigation = useNavigation>(); const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); @@ -42,6 +42,9 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi }).then((result) => { onVisibilityChange?.(false); if (result.action === ModalActions.CONFIRM) { + if (onConfirm) { + onConfirm?.(); + } setNavigationActionToMicrotaskQueue(navigateBack); } else { blockedNavigationAction.current = undefined; @@ -49,7 +52,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi onCancel?.(); } }); - }, [showConfirmModal, translate, navigateBack, onCancel, onVisibilityChange]); + }, [showConfirmModal, translate, navigateBack, onCancel, onConfirm, onVisibilityChange]); useBeforeRemove((e) => { if (!getHasUnsavedChanges() || shouldNavigateBack.current) { diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index 383335dd7e91..acd7b2fe16bd 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -2,6 +2,7 @@ type UseDiscardChangesConfirmationOptions = { getHasUnsavedChanges: () => boolean; onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; + onConfirm?: () => void; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 70084dd6433b..aa114b2efc84 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -371,14 +371,11 @@ function IOURequestStepDistanceOdometer({ const navigateBack = useCallback(() => { if (isEditingConfirmation) { - backupHandledManually.current = true; - restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft, () => { - Navigation.goBack(confirmationRoute); - }); + Navigation.goBack(confirmationRoute); return; } Navigation.goBack(); - }, [isEditingConfirmation, confirmationRoute, transactionID, isTransactionDraft]); + }, [confirmationRoute, isEditingConfirmation]); const handlePressStartImage = useCallback(() => { if (odometerStartImage) { @@ -555,6 +552,12 @@ function IOURequestStepDistanceOdometer({ const hasImageChanges = transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current; return hasReadingChanges || hasImageChanges; }, + onConfirm: isEditingConfirmation + ? () => { + backupHandledManually.current = true; + restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + } + : undefined, }); return ( From 13f1f51dd2cebf061650d6b85e8ad2bbf22321e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 9 Apr 2026 23:00:37 +0200 Subject: [PATCH 05/10] fix: remove unused onComplete from backup transaction cleanup utils --- src/libs/actions/TransactionEdit.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index bd24c03731e7..44ad377b5e05 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -187,7 +187,7 @@ function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUs return newTransaction; } -function removeBackupTransactionWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { +function removeBackupTransactionWithImageCleanup(transactionID: string | undefined, isDraft: boolean) { if (!transactionID) { return; } @@ -202,14 +202,13 @@ function removeBackupTransactionWithImageCleanup(transactionID: string | undefin revokeOdometerImageUri(backupTransaction?.comment?.odometerStartImage, currentTransaction?.comment?.odometerStartImage); revokeOdometerImageUri(backupTransaction?.comment?.odometerEndImage, currentTransaction?.comment?.odometerEndImage); removeBackupTransaction(transactionID); - onComplete?.(); }, }); }, }); } -function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean, onComplete?: () => void) { +function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean) { if (!transactionID) { return; } @@ -225,7 +224,6 @@ function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: str revokeOdometerImageUri(currentTransaction?.comment?.odometerEndImage, backupTransaction?.comment?.odometerEndImage); Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null); removeBackupTransaction(transactionID); - onComplete?.(); }, }); }, From 8581be41a0326435d6087da6de047ab6ea29047f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 10 Apr 2026 15:11:30 +0200 Subject: [PATCH 06/10] fix: dwait odometer rollback before confirming discard navigation --- .../index.native.ts | 11 +++-- .../useDiscardChangesConfirmation/index.ts | 13 ++++-- .../useDiscardChangesConfirmation/types.ts | 2 +- src/libs/actions/TransactionEdit.ts | 45 ++++++++++--------- .../step/IOURequestStepDistanceOdometer.tsx | 4 +- 5 files changed, 44 insertions(+), 31 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 361b5dc5c372..3935a4491b29 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -4,6 +4,7 @@ import {useCallback, useRef, useState} from 'react'; 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 type UseDiscardChangesConfirmationOptions from './types'; @@ -46,10 +47,12 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange navigationRef.current?.goBack(); } }; - if (onConfirm) { - onConfirm?.(); - } - confirmNavigation(); + Promise.resolve() + .then(() => onConfirm?.()) + .catch((error: unknown) => { + Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); + }) + .finally(confirmNavigation); }); }, [getHasUnsavedChanges, onVisibilityChange, onConfirm, showConfirmModal, translate], diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 768cf27795bc..9a6dc9cb31a1 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -5,6 +5,7 @@ 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 navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -42,10 +43,14 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi }).then((result) => { onVisibilityChange?.(false); if (result.action === ModalActions.CONFIRM) { - if (onConfirm) { - onConfirm?.(); - } - setNavigationActionToMicrotaskQueue(navigateBack); + Promise.resolve() + .then(() => onConfirm?.()) + .catch((error: unknown) => { + Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); + }) + .finally(() => { + setNavigationActionToMicrotaskQueue(navigateBack); + }); } else { blockedNavigationAction.current = undefined; shouldNavigateBack.current = false; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index acd7b2fe16bd..abfcbc6b76fd 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -2,7 +2,7 @@ type UseDiscardChangesConfirmationOptions = { getHasUnsavedChanges: () => boolean; onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; - onConfirm?: () => void; + onConfirm?: () => void | Promise; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 44ad377b5e05..cde3c6107bdd 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -54,12 +54,12 @@ function createBackupTransaction(transaction: OnyxEntry, isDraft: b /** * Removes a transaction from Onyx that was only used temporary in the edit flow */ -function removeBackupTransaction(transactionID: string | undefined) { +function removeBackupTransaction(transactionID: string | undefined): Promise { if (!transactionID) { - return; + return Promise.resolve(); } - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, null); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, null); } function restoreOriginalTransactionFromBackup(transactionID: string | undefined, isDraft: boolean) { @@ -208,25 +208,30 @@ function removeBackupTransactionWithImageCleanup(transactionID: string | undefin }); } -function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean) { +function restoreOriginalTransactionFromBackupWithImageCleanup(transactionID: string | undefined, isDraft: boolean): Promise { if (!transactionID) { - return; + return Promise.resolve(); } - connection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, - callback: (backupTransaction) => { - Onyx.disconnect(connection); - const currentConn = Onyx.connectWithoutView({ - key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, - callback: (currentTransaction) => { - Onyx.disconnect(currentConn); - revokeOdometerImageUri(currentTransaction?.comment?.odometerStartImage, backupTransaction?.comment?.odometerStartImage); - revokeOdometerImageUri(currentTransaction?.comment?.odometerEndImage, backupTransaction?.comment?.odometerEndImage); - Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null); - removeBackupTransaction(transactionID); - }, - }); - }, + + return new Promise((resolve, reject) => { + connection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`, + callback: (backupTransaction) => { + Onyx.disconnect(connection); + const currentConn = Onyx.connectWithoutView({ + key: `${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + callback: (currentTransaction) => { + Onyx.disconnect(currentConn); + revokeOdometerImageUri(currentTransaction?.comment?.odometerStartImage, backupTransaction?.comment?.odometerStartImage); + revokeOdometerImageUri(currentTransaction?.comment?.odometerEndImage, backupTransaction?.comment?.odometerEndImage); + Onyx.set(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction ?? null) + .then(() => removeBackupTransaction(transactionID)) + .then(() => resolve()) + .catch((error) => reject(error)); + }, + }); + }, + }); }); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index aa114b2efc84..b38a19812a81 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -553,9 +553,9 @@ function IOURequestStepDistanceOdometer({ return hasReadingChanges || hasImageChanges; }, onConfirm: isEditingConfirmation - ? () => { + ? async () => { backupHandledManually.current = true; - restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); } : undefined, }); From e47cc1d4db0e05c717ce7b2481f0f1a438df9615 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 17 Apr 2026 00:02:10 +0200 Subject: [PATCH 07/10] fix: ensure that navigation back happens only when onConfirm finishes succesfully --- src/hooks/useDiscardChangesConfirmation/index.native.ts | 5 +++-- src/hooks/useDiscardChangesConfirmation/index.ts | 8 +++++--- .../iou/request/step/IOURequestStepDistanceOdometer.tsx | 2 +- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 3935a4491b29..34d3e6d71b97 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -49,10 +49,11 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange }; Promise.resolve() .then(() => onConfirm?.()) + .then(confirmNavigation) .catch((error: unknown) => { Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); - }) - .finally(confirmNavigation); + blockedNavigationAction.current = undefined; + }); }); }, [getHasUnsavedChanges, onVisibilityChange, onConfirm, showConfirmModal, translate], diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 9a6dc9cb31a1..4ec1f161722a 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -45,11 +45,13 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi if (result.action === ModalActions.CONFIRM) { Promise.resolve() .then(() => onConfirm?.()) + .then(() => { + setNavigationActionToMicrotaskQueue(navigateBack); + }) .catch((error: unknown) => { Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); - }) - .finally(() => { - setNavigationActionToMicrotaskQueue(navigateBack); + blockedNavigationAction.current = undefined; + shouldNavigateBack.current = false; }); } else { blockedNavigationAction.current = undefined; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 98d4a3c06dfd..ddf13bb8dbc6 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -564,8 +564,8 @@ function IOURequestStepDistanceOdometer({ }, onConfirm: isEditingConfirmation ? async () => { - backupHandledManually.current = true; await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; } : undefined, }); From af83941bac77a37b111fb8a7f8e579f4bc8511e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 21 Apr 2026 15:55:52 +0200 Subject: [PATCH 08/10] fix: remove redundant shouldEnableDiscardConfirmation const and replace it with isEditing --- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ddf13bb8dbc6..071cacf6ef40 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -139,7 +139,6 @@ function IOURequestStepDistanceOdometer({ const isTransactionDraft = shouldUseTransactionDraft(action, iouType); const currentUserAccountIDParam = currentUserPersonalDetails.accountID; const currentUserEmailParam = currentUserPersonalDetails.login ?? ''; - const shouldEnableDiscardConfirmation = !isEditing; const isFocused = useIsFocused(); const shouldUseDefaultExpensePolicy = useMemo( @@ -555,7 +554,7 @@ function IOURequestStepDistanceOdometer({ useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { - if (!isFocused || !shouldEnableDiscardConfirmation || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current) { + if (!isFocused || isEditing || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current) { return false; } const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; From 4eac84cd9ce15ea10cdab029d32cff496044148c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 21 Apr 2026 16:08:43 +0200 Subject: [PATCH 09/10] fix: apply onCancel and onVisibilityChange to useDiscardChangesConfirmation in IOURequestStepDistanceOdometer --- .../step/IOURequestStepDistanceOdometer.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 071cacf6ef40..9cbbd784e63f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -1,7 +1,7 @@ import {useIsFocused} from '@react-navigation/native'; import lodashIsEmpty from 'lodash/isEmpty'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {View} from 'react-native'; +import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; @@ -80,12 +80,14 @@ function IOURequestStepDistanceOdometer({ const startReadingInputRef = useRef(null); const endReadingInputRef = useRef(null); + const lastFocusedInputRef = useRef(null); const [startReading, setStartReading] = useState(''); const [endReading, setEndReading] = useState(''); const [formError, setFormError] = useState(''); // Key to force TextInput remount when resetting state after tab switch const [inputKey, setInputKey] = useState(0); + const [isDiscardModalVisible, setIsDiscardModalVisible] = useState(false); // Track initial values for DiscardChangesConfirmation const initialStartReadingRef = useRef(''); @@ -553,6 +555,12 @@ function IOURequestStepDistanceOdometer({ }; useDiscardChangesConfirmation({ + onCancel: () => { + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + lastFocusedInputRef.current?.focus(); + }); + }, getHasUnsavedChanges: () => { if (!isFocused || isEditing || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current) { return false; @@ -567,6 +575,7 @@ function IOURequestStepDistanceOdometer({ backupHandledManually.current = true; } : undefined, + onVisibilityChange: setIsDiscardModalVisible, }); return ( @@ -592,6 +601,10 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleStartReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} + editable={!isDiscardModalVisible} + onFocus={() => { + lastFocusedInputRef.current = startReadingInputRef.current; + }} /> {!isEditing && ( @@ -634,6 +647,10 @@ function IOURequestStepDistanceOdometer({ onChangeText={handleEndReadingChange} keyboardType={CONST.KEYBOARD_TYPE.DECIMAL_PAD} inputMode={CONST.INPUT_MODE.DECIMAL} + editable={!isDiscardModalVisible} + onFocus={() => { + lastFocusedInputRef.current = endReadingInputRef.current; + }} /> {!isEditing && ( From 90c3ed70ad8acf79c14449b9ef077f7dbbf8fa6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 21 Apr 2026 17:27:36 +0200 Subject: [PATCH 10/10] fix: add onCancel support to useDiscardChangesConfirmation native implementation --- src/hooks/useDiscardChangesConfirmation/index.native.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 34d3e6d71b97..f44dd33c6231 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -8,7 +8,7 @@ import Log from '@libs/Log'; import navigationRef from '@libs/Navigation/navigationRef'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); @@ -36,6 +36,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange }).then((result) => { onVisibilityChange?.(false); if (result.action !== ModalActions.CONFIRM) { + onCancel?.(); return; } const confirmNavigation = () => { @@ -56,7 +57,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onVisibilityChange }); }); }, - [getHasUnsavedChanges, onVisibilityChange, onConfirm, showConfirmModal, translate], + [getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm, showConfirmModal, translate], ), ); }