From 6dabb95d450ad42b98ea7c1db3e76e4a8bcc31eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 5 May 2026 17:22:44 +0200 Subject: [PATCH 01/42] feature: show discard modal when leaving odometer tab with unsaved changes --- .../iou/request/DistanceRequestStartPage.tsx | 207 ++++++++++++------ .../iou/request/DistanceTabGuardContext.tsx | 35 +++ .../step/IOURequestStepDistanceOdometer.tsx | 47 ++-- 3 files changed, 207 insertions(+), 82 deletions(-) create mode 100644 src/pages/iou/request/DistanceTabGuardContext.tsx diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 84aa6620cb55..0a559666d38c 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -1,9 +1,14 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import {TabActions} from '@react-navigation/native'; +import type {EventArg} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; +import type {TabSelectorProps} from '@components/TabSelector/types'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -21,6 +26,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {SelectedTabRequest} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import DistanceTabGuardContext from './DistanceTabGuardContext'; +import type {DistanceTabGuard, RegisterDistanceTabGuard} from './DistanceTabGuardContext'; import IOURequestStepDistanceGPS from './step/IOURequestStepDistanceGPS'; import IOURequestStepDistanceManual from './step/IOURequestStepDistanceManual'; import IOURequestStepDistanceMap from './step/IOURequestStepDistanceMap'; @@ -96,6 +103,79 @@ function DistanceRequestStartPage({ return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); + const activeGuardRef = useRef(null); + const tabNavigationRef = useRef(null); + const tabStateRef = useRef(null); + const isDiscardModalOpenRef = useRef(false); + + const registerTabGuard = useCallback((guard) => { + activeGuardRef.current = guard; + return () => { + if (activeGuardRef.current?.tabName !== guard.tabName) { + return; + } + activeGuardRef.current = null; + }; + }, []); + + const {showConfirmModal} = useConfirmModal(); + + const tabBar = useCallback((tabBarProps: TabSelectorProps) => { + tabNavigationRef.current = tabBarProps.navigation; + tabStateRef.current = tabBarProps.state; + return ( + + ); + }, []); + + const screenListeners = useMemo( + () => ({ + tabPress: (event: EventArg<'tabPress', true, undefined>) => { + if (isDiscardModalOpenRef.current) { + return; + } + const guard = activeGuardRef.current; + const state = tabStateRef.current; + if (!guard || !state) { + return; + } + const currentRouteName = state.routes.at(state.index)?.name; + if (currentRouteName !== guard.tabName) { + return; + } + if (!guard.getHasUnsavedChanges()) { + return; + } + const targetRoute = state.routes.find((tabRoute) => tabRoute.key === event.target); + if (!targetRoute) { + return; + } + event.preventDefault(); + isDiscardModalOpenRef.current = true; + showConfirmModal({ + title: translate('discardChangesConfirmation.title'), + prompt: translate('discardChangesConfirmation.body'), + danger: true, + confirmText: translate('discardChangesConfirmation.confirmText'), + cancelText: translate('common.cancel'), + shouldIgnoreBackHandlerDuringTransition: true, + }).then((result) => { + isDiscardModalOpenRef.current = false; + if (result.action !== ModalActions.CONFIRM) { + return; + } + Promise.resolve(guard.onDiscard()).then(() => { + tabNavigationRef.current?.dispatch(TabActions.jumpTo(targetRoute.name)); + }); + }); + }, + }), + [showConfirmModal, translate], + ); + return ( - - - - - - - {() => ( - - - - )} - - - {() => ( - - - - )} - - - {() => ( - - - - )} - - - {() => ( - - - - )} - - - + + + + + + + + {() => ( + + + + )} + + + {() => ( + + + + )} + + + {() => ( + + + + )} + + + {() => ( + + + + )} + + + + ); diff --git a/src/pages/iou/request/DistanceTabGuardContext.tsx b/src/pages/iou/request/DistanceTabGuardContext.tsx new file mode 100644 index 000000000000..7d6dd51a3e4b --- /dev/null +++ b/src/pages/iou/request/DistanceTabGuardContext.tsx @@ -0,0 +1,35 @@ +import {createContext, useContext, useEffect, useRef} from 'react'; + +type DistanceTabGuard = { + tabName: string; + getHasUnsavedChanges: () => boolean; + onDiscard: () => void | Promise; +}; + +type RegisterDistanceTabGuard = (guard: DistanceTabGuard) => () => void; + +const DistanceTabGuardContext = createContext(null); + +function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard: () => void | Promise) { + const register = useContext(DistanceTabGuardContext); + const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard}); + + useEffect(() => { + guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard}; + }); + + useEffect(() => { + if (!register) { + return undefined; + } + return register({ + tabName, + getHasUnsavedChanges: () => guardCallbacksRef.current.getHasUnsavedChanges(), + onDiscard: () => guardCallbacksRef.current.onDiscard(), + }); + }, [register, tabName]); +} + +export default DistanceTabGuardContext; +export {useRegisterDistanceTabGuard}; +export type {DistanceTabGuard, RegisterDistanceTabGuard}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 0306bee3157b..ce0eb97a5337 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -33,7 +33,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestDistance} from '@libs/actions/IOU'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {clearOdometerDraft, saveOdometerDraft, setMoneyRequestOdometerReading} from '@libs/actions/OdometerTransactionUtils'; +import {clearOdometerDraft, saveOdometerDraft, removeMoneyRequestOdometerImage, setMoneyRequestOdometerReading} from '@libs/actions/OdometerTransactionUtils'; import {restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -41,10 +41,10 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; import {startSpan} from '@libs/telemetry/activeSpans'; +import {useRegisterDistanceTabGuard} from '@pages/iou/request/DistanceTabGuardContext'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -171,7 +171,6 @@ function IOURequestStepDistanceOdometer({ initialStartImageRef, initialEndImageRef, resetOdometerLocalState, - hasInitializedRefs, } = useOdometerReadingsState({currentTransaction, isEditing, selectedTab, isLoadingSelectedTab, hasVerifiedBlobs, odometerDraft}); useEffect(() => { @@ -472,6 +471,30 @@ function IOURequestStepDistanceOdometer({ navigateToNextPage(); }; + const getHasUnsavedChanges = () => { + if (!isFocused || isEditing || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current || backupHandledManually.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; + }; + + const handleTabSwitchDiscard = async () => { + if (isEditingConfirmation) { + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; + } else { + setMoneyRequestOdometerReading(transactionID, null, null, isTransactionDraft); + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, isTransactionDraft, true); + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.END, isTransactionDraft, true); + } + resetOdometerLocalState(); + setFormError(''); + }; + + useRegisterDistanceTabGuard(CONST.TAB_REQUEST.DISTANCE_ODOMETER, getHasUnsavedChanges, handleTabSwitchDiscard); + const handleSaveForLater = useCallback(async () => { shouldBypassDiscardConfirmationRef.current = true; @@ -511,23 +534,7 @@ function IOURequestStepDistanceOdometer({ lastFocusedInputRef.current?.focus(); }); }, - getHasUnsavedChanges: () => { - if ( - !isFocused || - isEditing || - shouldBypassDiscardConfirmationRef.current || - didSaveEditingConfirmationRef.current || - !hasInitializedRefs.current || - backupHandledManually.current - ) { - return false; - } - const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; - const hasImageChanges = - getOdometerImageUri(transaction?.comment?.odometerStartImage) !== getOdometerImageUri(initialStartImageRef.current) || - getOdometerImageUri(transaction?.comment?.odometerEndImage) !== getOdometerImageUri(initialEndImageRef.current); - return hasReadingChanges || hasImageChanges; - }, + getHasUnsavedChanges, onConfirm: isEditingConfirmation ? async () => { await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); From 0e732022b11c6775c838e394617bb1fb382c2ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 11 May 2026 17:50:16 +0200 Subject: [PATCH 02/42] fix: don't show discard modal when tapping the active distance tab --- src/pages/iou/request/DistanceRequestStartPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 0a559666d38c..02003fa4b7d9 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -150,7 +150,7 @@ function DistanceRequestStartPage({ return; } const targetRoute = state.routes.find((tabRoute) => tabRoute.key === event.target); - if (!targetRoute) { + if (!targetRoute || targetRoute.name === currentRouteName) { return; } event.preventDefault(); From 203f488dbd8447e1a0e002fbbf29159de50bc3ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 11 May 2026 18:10:25 +0200 Subject: [PATCH 03/42] fix: prevent distance tab switch while discard modal is opening --- src/pages/iou/request/DistanceRequestStartPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 02003fa4b7d9..ac27d352784c 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -135,6 +135,7 @@ function DistanceRequestStartPage({ () => ({ tabPress: (event: EventArg<'tabPress', true, undefined>) => { if (isDiscardModalOpenRef.current) { + event.preventDefault(); return; } const guard = activeGuardRef.current; From bb99470bd417579eb5b106b4df0d034ab18d3b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 11 May 2026 19:15:21 +0200 Subject: [PATCH 04/42] fix: catch tab-discard callback errors before jumping tabs --- src/pages/iou/request/DistanceRequestStartPage.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index ac27d352784c..9eeb1109ed30 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -16,6 +16,7 @@ import useResetIOUType from '@hooks/useResetIOUType'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {getPayeeName} from '@libs/ReportUtils'; @@ -168,9 +169,13 @@ function DistanceRequestStartPage({ if (result.action !== ModalActions.CONFIRM) { return; } - Promise.resolve(guard.onDiscard()).then(() => { - tabNavigationRef.current?.dispatch(TabActions.jumpTo(targetRoute.name)); - }); + Promise.resolve(guard.onDiscard()) + .then(() => { + tabNavigationRef.current?.dispatch(TabActions.jumpTo(targetRoute.name)); + }) + .catch((error: unknown) => { + Log.warn('[DistanceRequestStartPage] Failed to run onDiscard callback', {error}); + }); }); }, }), From 150381eeb2bb6d2f55284c29d6deebf0b2ae43b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 11 May 2026 20:14:21 +0200 Subject: [PATCH 05/42] fix: handle synchronous throws from tab discard callback --- src/pages/iou/request/DistanceRequestStartPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 9eeb1109ed30..a183bc13a28e 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -169,7 +169,8 @@ function DistanceRequestStartPage({ if (result.action !== ModalActions.CONFIRM) { return; } - Promise.resolve(guard.onDiscard()) + Promise.resolve() + .then(() => guard.onDiscard()) .then(() => { tabNavigationRef.current?.dispatch(TabActions.jumpTo(targetRoute.name)); }) From 196b7b7e971a0a9f02a2d8a992862e55e1d45560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 11 May 2026 23:17:17 +0200 Subject: [PATCH 06/42] fix: prevent false discard modal after editing odometer from confirmation --- .../step/IOURequestStepDistanceOdometer.tsx | 87 ++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ce0eb97a5337..1bdb58da7182 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -33,8 +33,8 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestDistance} from '@libs/actions/IOU'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {clearOdometerDraft, saveOdometerDraft, removeMoneyRequestOdometerImage, setMoneyRequestOdometerReading} from '@libs/actions/OdometerTransactionUtils'; -import {restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; +import {clearOdometerDraft, saveOdometerDraft, removeMoneyRequestOdometerImage, setMoneyRequestOdometerReading, isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; +import {createBackupTransaction, removeBackupTransactionWithImageCleanup, restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; @@ -53,6 +53,7 @@ import SCREENS from '@src/SCREENS'; import type Transaction from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import { getOdometerImageUri } from '@libs/OdometerImageUtils'; import useOdometerImageHandlers from './IOURequestStepDistance/hooks/useOdometerImageHandlers'; import useOdometerNavigation from './IOURequestStepDistance/hooks/useOdometerNavigation'; import useOdometerReadingsState from './IOURequestStepDistance/hooks/useOdometerReadingsState'; @@ -190,6 +191,64 @@ function IOURequestStepDistanceOdometer({ backupHandledManuallyRef: backupHandledManually, }); + // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) + // This updates the current state, but NOT the initial refs (those are set only once on mount) + useEffect(() => { + const currentStart = currentTransaction?.comment?.odometerStart; + const currentEnd = currentTransaction?.comment?.odometerEnd; + + const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); + const hasLocalState = startReadingRef.current || endReadingRef.current; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + + // External resync: txn was edited elsewhere with no in-flight typing here, making it the new baseline. + // Includes image URI diffs so an isEditingConfirmation Save that only touched the photos still resyncs. + const isExternalResync = + hasTransactionData && + hasInitializedRefs.current && + !userHasUnsavedTypingRef.current && + (startValue !== startReadingRef.current || + endValue !== endReadingRef.current || + getOdometerImageUri(currentTransaction?.comment?.odometerStartImage) !== getOdometerImageUri(initialStartImageRef.current) || + getOdometerImageUri(currentTransaction?.comment?.odometerEndImage) !== getOdometerImageUri(initialEndImageRef.current)); + + // Only initialize if: + // 1. We haven't initialized yet AND transaction has data, OR + // 2. We're editing and transaction has data (to load existing values), OR + // 3. Transaction has data but local state is empty (user navigated back from another page), OR + // 4. An external resync arrived - the typing-flag inside isExternalResync avoids clobbering + // in-progress keystrokes here. + const shouldInitialize = + (!hasInitializedRefs.current && hasTransactionData) || + (isEditing && hasTransactionData && !hasLocalState) || + (hasTransactionData && !hasLocalState && hasInitializedRefs.current) || + isExternalResync; + + if (shouldInitialize) { + if (startValue || endValue) { + setStartReading(startValue); + setEndReading(endValue); + startReadingRef.current = startValue; + endReadingRef.current = endValue; + } + } + + // Slide the discard-changes baseline up so leaving doesn't flag the externally-saved value as unsaved + if (isExternalResync) { + initialStartReadingRef.current = startValue; + initialEndReadingRef.current = endValue; + initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; + initialEndImageRef.current = currentTransaction?.comment?.odometerEndImage; + } + }, [ + currentTransaction?.comment?.odometerStart, + currentTransaction?.comment?.odometerEnd, + currentTransaction?.comment?.odometerStartImage, + currentTransaction?.comment?.odometerEndImage, + isEditing, + ]); + const navigateToNextStep = useOdometerNavigation({ iouType, report, @@ -216,6 +275,26 @@ function IOURequestStepDistanceOdometer({ personalOutputCurrency: personalPolicy?.outputCurrency, }); + useEffect(() => { + if (!isEditingConfirmation) { + return () => {}; + } + createBackupTransaction(transaction, isTransactionDraft, true); + + return () => { + if (backupHandledManually.current) { + return; + } + if (didSaveEditingConfirmationRef.current) { + removeBackupTransactionWithImageCleanup(transactionID, isTransactionDraft); + return; + } + restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + }; + // We only want to create the backup once on mount and restore/remove it on unmount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Calculate total distance - updated live after every input change const totalDistance = (() => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); @@ -475,6 +554,10 @@ function IOURequestStepDistanceOdometer({ if (!isFocused || isEditing || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current || backupHandledManually.current) { return false; } + // Draft matching the transaction means changes are persisted; the typing guard prevents suppressing the modal mid-edit (typed values aren't in the transaction yet). + if (!userHasUnsavedTypingRef.current && !!odometerDraft && !isOdometerDraftPendingHydration(odometerDraft, currentTransaction?.comment)) { + 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 fed845d47693b932b1fe2d8121c7a20cb7d4daf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 13 May 2026 11:16:03 +0200 Subject: [PATCH 07/42] fix: pull missing hasInitializedRefs ref into odometer step --- src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 1bdb58da7182..ff26303c3330 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -172,6 +172,7 @@ function IOURequestStepDistanceOdometer({ initialStartImageRef, initialEndImageRef, resetOdometerLocalState, + hasInitializedRefs, } = useOdometerReadingsState({currentTransaction, isEditing, selectedTab, isLoadingSelectedTab, hasVerifiedBlobs, odometerDraft}); useEffect(() => { @@ -241,6 +242,9 @@ function IOURequestStepDistanceOdometer({ initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; initialEndImageRef.current = currentTransaction?.comment?.odometerEndImage; } + // The refs and setters destructured from `useOdometerReadingsState` are stable; we intentionally omit them + // to keep this effect driven only by transaction changes. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, From 2f6636a5f7a5da5965c4c8c62352987a5c6659fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 13 May 2026 14:55:32 +0200 Subject: [PATCH 08/42] chore: prettier run --- .../request/step/IOURequestStepDistanceOdometer.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ff26303c3330..068ea4c61f91 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -33,7 +33,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestDistance} from '@libs/actions/IOU'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; -import {clearOdometerDraft, saveOdometerDraft, removeMoneyRequestOdometerImage, setMoneyRequestOdometerReading, isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; +import { + clearOdometerDraft, + isOdometerDraftPendingHydration, + removeMoneyRequestOdometerImage, + saveOdometerDraft, + setMoneyRequestOdometerReading, +} from '@libs/actions/OdometerTransactionUtils'; import {createBackupTransaction, removeBackupTransactionWithImageCleanup, restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -41,6 +47,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; +import {getOdometerImageUri} from '@libs/OdometerImageUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; import {startSpan} from '@libs/telemetry/activeSpans'; @@ -53,7 +60,6 @@ import SCREENS from '@src/SCREENS'; import type Transaction from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import { getOdometerImageUri } from '@libs/OdometerImageUtils'; import useOdometerImageHandlers from './IOURequestStepDistance/hooks/useOdometerImageHandlers'; import useOdometerNavigation from './IOURequestStepDistance/hooks/useOdometerNavigation'; import useOdometerReadingsState from './IOURequestStepDistance/hooks/useOdometerReadingsState'; From 4ab9a7357f90a85b9eb3f8141f37a56285033d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 8 Jun 2026 20:18:15 +0200 Subject: [PATCH 09/42] fix: web browser-back now shows the discard modal for odometer tab Replace per-tab browser history for the distance-create tabs (UNSTABLE_router, web-only, allowlist-scoped) so browser-back closes the RHP instead of cycling tabs, and the existing discard guard fires. Gate the odometer step's discard hook to standalone screens to avoid a double modal. --- src/libs/Navigation/OnyxTabNavigator.tsx | 7 ++ .../OnyxTabNavigatorConfig/index.ts | 12 +- .../OnyxTabNavigatorConfig/index.web.ts | 12 +- .../getTabHistoryReplacementRouter.ts | 81 +++++++++++++ .../goForwardInBrowserHistory/index.ts | 7 ++ .../goForwardInBrowserHistory/index.web.ts | 14 +++ .../iou/request/DistanceRequestStartPage.tsx | 55 ++++++++- .../iou/request/DistanceTabGuardContext.tsx | 8 +- .../step/IOURequestStepDistanceOdometer.tsx | 55 ++++++--- .../getTabHistoryReplacementRouter.test.ts | 112 ++++++++++++++++++ 10 files changed, 331 insertions(+), 32 deletions(-) create mode 100644 src/libs/Navigation/getTabHistoryReplacementRouter.ts create mode 100644 src/libs/Navigation/goForwardInBrowserHistory/index.ts create mode 100644 src/libs/Navigation/goForwardInBrowserHistory/index.web.ts create mode 100644 tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index b87a155c1653..0c8535803d15 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -15,6 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import getTabHistoryReplacementRouter from './getTabHistoryReplacementRouter'; import {defaultScreenOptions} from './OnyxTabNavigatorConfig'; type OnyxTabNavigatorProps = ChildrenProps & { @@ -107,6 +108,11 @@ function OnyxTabNavigator({ const tabNames = useMemo(() => getTabNames(children), [children]); + // On web, opts the allowlisted navigators into replacing (instead of pushing) browser history on tab + // switches, so the browser BACK button leaves the flow and fires the existing discard guards rather + // than silently popping a nested-tab URL. `undefined` (native / non-allowlisted) keeps the stock router. + const tabHistoryRouter = useMemo(() => getTabHistoryReplacementRouter(id), [id]); + const validInitialTab = selectedTab && tabNames.includes(selectedTab) ? selectedTab : defaultSelectedTab; const LazyPlaceholder = useCallback(() => { @@ -165,6 +171,7 @@ function OnyxTabNavigator({ (original: Router, Action>) => Partial, Action>>; + +/** + * Navigators whose tab switches should NOT add a browser-history entry + * + * Kept as a single allowlist so the behavior can be widened later by adding an id here. Scoped today to + * the distance-create flow, which is the only tab navigator that loses unsaved local state (the odometer + * readings) when the browser BACK button silently pops a nested-tab URL + */ +const NAVIGATORS_WITH_REPLACED_TAB_HISTORY = new Set([CONST.TAB.DISTANCE_REQUEST_TYPE]); + +// Action types that switch tabs. `NAVIGATE` comes from URL/linking-driven switches; `JUMP_TO` / +// `NAVIGATE_DEPRECATED` are React Navigation's internal TabRouter action types (no exported constant to use) +const TAB_SWITCH_ACTION_TYPES = new Set([CONST.NAVIGATION.ACTION_TYPE.NAVIGATE, 'JUMP_TO', 'NAVIGATE_DEPRECATED']); + +/** + * Builds the `UNSTABLE_router` override that collapses the tab navigator's `history` down to just the + * focused route on every tab switch + * + * Why: the distance-create tabs are routed URL sub-paths. By default each tab switch grows the tab + * navigator's `state.history`, which makes React Navigation's `useLinking` perform a browser + * `history.push` (a new history entry). The browser BACK button then pops *within* the nested tab + * navigator - switching tabs silently and wiping the odometer's unsaved local readings, bypassing the + * `beforeRemove`/`transitionStart`/`tabPress` discard guards + * + * Keeping the history length constant (always 1) makes `useLinking`'s history delta 0, so it calls + * `history.replace` instead of `history.push`: the URL still updates (refresh/deep-link keep working) + * but no browser-history entry is added. Browser BACK then pops the whole DISTANCE_CREATE screen and + * fires the existing `beforeRemove` discard guard - the same path as the header/hardware back button. + * As a bonus, an in-navigator `GO_BACK` on a length-1 history returns `null`, so back bubbles to the + * parent stack and closes the flow + * + * Exported separately from {@link getTabHistoryReplacementRouter} so the clamping behavior can be unit + * tested independently of the platform/allowlist gating + */ +function createTabHistoryReplacementRouter(): TabRouterOverrideFactory { + return (original) => ({ + getStateForAction(state, action, options) { + const nextState = original.getStateForAction(state, action, options); + + // `null` => the action isn't handled by this navigator and should bubble up to the parent + if (!nextState || !TAB_SWITCH_ACTION_TYPES.has(action.type)) { + return nextState; + } + + // Tab-switch actions always resolve to a full TabNavigationState (with `history`/`index`) + const tabState = nextState as TabNavigationState; + const focusedRoute = tabState.routes?.at(tabState.index); + if (!tabState.history || !focusedRoute) { + return nextState; + } + + return {...tabState, history: [{type: 'route', key: focusedRoute.key}]}; + }, + }); +} + +/** + * Returns the tab-history `UNSTABLE_router` override for the given navigator id, or `undefined` when the + * default (history-growing) behavior should be kept. Returns `undefined` on native (no browser history) + * and for any navigator not in {@link NAVIGATORS_WITH_REPLACED_TAB_HISTORY} + */ +function getTabHistoryReplacementRouter(id: string): TabRouterOverrideFactory | undefined { + if (!shouldReplaceTabHistoryOnTabSwitch || !NAVIGATORS_WITH_REPLACED_TAB_HISTORY.has(id)) { + return undefined; + } + + return createTabHistoryReplacementRouter(); +} + +export default getTabHistoryReplacementRouter; +export {createTabHistoryReplacementRouter, NAVIGATORS_WITH_REPLACED_TAB_HISTORY}; +export type {TabRouterOverrideFactory}; diff --git a/src/libs/Navigation/goForwardInBrowserHistory/index.ts b/src/libs/Navigation/goForwardInBrowserHistory/index.ts new file mode 100644 index 000000000000..1f000bbbaaa7 --- /dev/null +++ b/src/libs/Navigation/goForwardInBrowserHistory/index.ts @@ -0,0 +1,7 @@ +/** + * Native has no browser history, so there is nothing to restore. No-op counterpart of the web + * implementation (see `index.web.ts`) + */ +function goForwardInBrowserHistory() {} + +export default goForwardInBrowserHistory; diff --git a/src/libs/Navigation/goForwardInBrowserHistory/index.web.ts b/src/libs/Navigation/goForwardInBrowserHistory/index.web.ts new file mode 100644 index 000000000000..b2a244b401d5 --- /dev/null +++ b/src/libs/Navigation/goForwardInBrowserHistory/index.web.ts @@ -0,0 +1,14 @@ +/** + * Moves the browser one entry forward, undoing a browser BACK press that we want to veto + * + * The browser BACK button moves the history pointer (and fires `popstate`) BEFORE JS can intercept it, + * so react-navigation's `beforeRemove` `preventDefault()` keeps the screen mounted but cannot undo the + * URL change. Without restoring the pointer the URL stays desynced and react-navigation keeps + * re-dispatching the (blocked) RESET - re-opening the discard modal on every cancel. Going forward + * resyncs the URL with the retained navigation state + */ +function goForwardInBrowserHistory() { + window.history.go(1); +} + +export default goForwardInBrowserHistory; diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 12fdf7a2631a..a473d6820675 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -8,7 +8,9 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; import type {TabSelectorProps} from '@components/TabSelector/types'; +import useBeforeRemove from '@hooks/useBeforeRemove'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -17,6 +19,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Log from '@libs/Log'; +import goForwardInBrowserHistory from '@libs/Navigation/goForwardInBrowserHistory'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {getPayeeName} from '@libs/ReportUtils'; @@ -39,6 +42,48 @@ type DistanceRequestStartPageProps = WithWritableReportOrNotFoundProps, isDiscardModalOpenRef: React.RefObject) { + const getActiveGuardHasUnsavedChanges = useCallback(() => activeGuardRef.current?.getHasUnsavedChanges() ?? false, [activeGuardRef]); + const discardActiveGuardChanges = useCallback(() => activeGuardRef.current?.onDiscard(), [activeGuardRef]); + const cancelActiveGuardDiscard = useCallback(() => activeGuardRef.current?.onCancel?.(), [activeGuardRef]); + const setDiscardModalVisibility = useCallback( + (visible: boolean) => { + // eslint-disable-next-line no-param-reassign + isDiscardModalOpenRef.current = visible; + }, + [isDiscardModalOpenRef], + ); + + useDiscardChangesConfirmation({ + getHasUnsavedChanges: getActiveGuardHasUnsavedChanges, + onConfirm: discardActiveGuardChanges, + onCancel: cancelActiveGuardDiscard, + onVisibilityChange: setDiscardModalVisibility, + }); + + // Web browser-BACK moves the history pointer (popstate) before JS runs, so `beforeRemove` keeps the screen + // mounted but can't undo the URL - and since the transition is blocked, the hook's own `transitionStart` + // resync never fires. react-navigation then re-dispatches the blocked RESET, re-opening the modal on every + // cancel. Only browser-back produces a RESET here (header back = POP), so going forward on a + // RESET-with-unsaved-changes resyncs the URL and stops the loop. No-op on native. + useBeforeRemove( + useCallback( + (event) => { + if (event.data.action?.type !== CONST.NAVIGATION.ACTION_TYPE.RESET || !getActiveGuardHasUnsavedChanges()) { + return; + } + goForwardInBrowserHistory(); + }, + [getActiveGuardHasUnsavedChanges], + ), + ); +} + function DistanceRequestStartPage({ route, route: { @@ -125,12 +170,7 @@ function DistanceRequestStartPage({ const tabBar = useCallback((tabBarProps: TabSelectorProps) => { tabNavigationRef.current = tabBarProps.navigation; tabStateRef.current = tabBarProps.state; - return ( - - ); + return ; }, []); const screenListeners = useMemo( @@ -184,6 +224,9 @@ function DistanceRequestStartPage({ [showConfirmModal, translate], ); + // Discard modal + browser-back resync for whichever distance tab is active (header / hardware / browser back). + useActiveTabGuardDiscardConfirmation(activeGuardRef, isDiscardModalOpenRef); + return ( boolean; onDiscard: () => void | Promise; + onCancel?: () => void; }; type RegisterDistanceTabGuard = (guard: DistanceTabGuard) => () => void; const DistanceTabGuardContext = createContext(null); -function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard: () => void | Promise) { +function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard: () => void | Promise, onCancel?: () => void) { const register = useContext(DistanceTabGuardContext); - const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard}); + const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard, onCancel}); useEffect(() => { - guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard}; + guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard, onCancel}; }); useEffect(() => { @@ -26,6 +27,7 @@ function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () = tabName, getHasUnsavedChanges: () => guardCallbacksRef.current.getHasUnsavedChanges(), onDiscard: () => guardCallbacksRef.current.onDiscard(), + onCancel: () => guardCallbacksRef.current.onCancel?.(), }); }, [register, tabName]); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index ccfdccec6fd2..f5a30a1a1bc9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -76,6 +76,23 @@ type IOURequestStepDistanceOdometerProps = WithCurrentUserPersonalDetailsProps & transaction: OnyxEntry; }; +type StandaloneDiscardGuardProps = { + getHasUnsavedChanges: () => boolean; + onCancel: () => void; + onConfirm?: () => Promise; + onVisibilityChange: (visible: boolean) => void; +}; + +/** + * A component (not an inline hook) so the discard hook runs only on the standalone screen — in the + * `DISTANCE_CREATE` tab flow `DistanceRequestStartPage` owns discard, and running it here too would + * register a second `beforeRemove` listener and show the discard modal twice. + */ +function StandaloneDiscardGuard({getHasUnsavedChanges, onCancel, onConfirm, onVisibilityChange}: StandaloneDiscardGuardProps) { + useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onConfirm, onVisibilityChange}); + return null; +} + function IOURequestStepDistanceOdometer({ report, route: { @@ -631,21 +648,10 @@ function IOURequestStepDistanceOdometer({ Navigation.closeRHPFlow(); }, [fromLocaleDigit, startReading, endReading, odometerStartImage, odometerEndImage, translate, setFormError]); - useDiscardChangesConfirmation({ - onCancel: () => { - InteractionManager.runAfterInteractions(() => { - lastFocusedInputRef.current?.focus(); - }); - }, - getHasUnsavedChanges, - onConfirm: isEditingConfirmation - ? async () => { - await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); - backupHandledManually.current = true; - } - : undefined, - onVisibilityChange: setIsDiscardModalVisible, - }); + // The discard hook runs only on the standalone screen (edit / edit-from-confirmation). In the + // `DISTANCE_CREATE` tab flow `DistanceRequestStartPage` owns discard; running it here too would show + // the discard modal twice on browser back. Rendered as a child because hooks can't be conditional. + const isStandaloneScreen = routeName !== SCREENS.MONEY_REQUEST.DISTANCE_CREATE; return ( + {isStandaloneScreen && ( + { + InteractionManager.runAfterInteractions(() => { + lastFocusedInputRef.current?.focus(); + }); + }} + onConfirm={ + isEditingConfirmation + ? async () => { + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; + } + : undefined + } + onVisibilityChange={setIsDiscardModalVisible} + /> + )} {/* Start Reading */} diff --git a/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts b/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts new file mode 100644 index 000000000000..f121bfeba118 --- /dev/null +++ b/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts @@ -0,0 +1,112 @@ +import type {NavigationAction, ParamListBase, Router, RouterConfigOptions, TabNavigationState} from '@react-navigation/native'; +import {TabActions, TabRouter} from '@react-navigation/native'; +import getTabHistoryReplacementRouter, {createTabHistoryReplacementRouter} from '@libs/Navigation/getTabHistoryReplacementRouter'; +import CONST from '@src/CONST'; + +// Force the web behavior (flag is `false` on native, `true` on web) so the allowlist gating is +// deterministic regardless of the platform jest resolves `OnyxTabNavigatorConfig` to. +jest.mock('@libs/Navigation/OnyxTabNavigatorConfig', () => ({ + shouldReplaceTabHistoryOnTabSwitch: true, + defaultScreenOptions: {}, +})); + +const ROUTE_NAMES = ['map', 'manual', 'gps', 'odometer']; +const INITIAL_ROUTE = 'map'; + +const CONFIG_OPTIONS: RouterConfigOptions = { + routeNames: ROUTE_NAMES, + routeParamList: {}, + routeGetIdList: {}, +}; + +/** A real TabRouter configured like the distance-create navigator (initialRoute back behavior). */ +function buildOriginalRouter(): Router, NavigationAction> { + return TabRouter({initialRouteName: INITIAL_ROUTE, backBehavior: 'initialRoute'}) as unknown as Router, NavigationAction>; +} + +/** The same TabRouter with our history-clamping override merged on top. */ +function buildWrappedRouter() { + const original = buildOriginalRouter(); + return {...original, ...createTabHistoryReplacementRouter()(original)}; +} + +function getInitialState(router: Router, NavigationAction>): TabNavigationState { + return router.getInitialState(CONFIG_OPTIONS); +} + +function focusedRouteName(state: TabNavigationState): string | undefined { + return state.routes.at(state.index)?.name; +} + +describe('createTabHistoryReplacementRouter', () => { + it('clamps history to the focused route when switching from the initial tab to another tab', () => { + const wrapped = buildWrappedRouter(); + const initialState = getInitialState(wrapped); + + const nextState = wrapped.getStateForAction(initialState, TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; + + expect(nextState).not.toBeNull(); + expect(focusedRouteName(nextState)).toBe('odometer'); + // The whole point: history stays length 1 so useLinking does history.replace (no new browser entry). + expect(nextState.history).toHaveLength(1); + expect(nextState.history.at(0)?.key).toBe(nextState.routes.at(nextState.index)?.key); + }); + + it('matches the stock TabRouter except for the clamped history length', () => { + const original = buildOriginalRouter(); + const wrapped = buildWrappedRouter(); + const initialState = getInitialState(original); + + const originalNext = original.getStateForAction(initialState, TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; + const wrappedNext = wrapped.getStateForAction(initialState, TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; + + // Stock router grows history (initial -> non-initial == length 2); ours collapses it to 1. + expect(originalNext.history.length).toBeGreaterThan(1); + expect(wrappedNext.history).toHaveLength(1); + // Everything else (index / focused route) is identical. + expect(wrappedNext.index).toBe(originalNext.index); + expect(focusedRouteName(wrappedNext)).toBe(focusedRouteName(originalNext)); + }); + + it('keeps history at length 1 when switching between two non-initial tabs', () => { + const wrapped = buildWrappedRouter(); + const onOdometer = wrapped.getStateForAction(getInitialState(wrapped), TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; + + const onManual = wrapped.getStateForAction(onOdometer, TabActions.jumpTo('manual'), CONFIG_OPTIONS) as TabNavigationState; + + expect(focusedRouteName(onManual)).toBe('manual'); + expect(onManual.history).toHaveLength(1); + }); + + it('returns null for an in-navigator GO_BACK on the clamped history so back bubbles to the parent', () => { + const wrapped = buildWrappedRouter(); + const onOdometer = wrapped.getStateForAction(getInitialState(wrapped), TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; + + const afterBack = wrapped.getStateForAction(onOdometer, {type: 'GO_BACK'} as NavigationAction, CONFIG_OPTIONS); + + expect(afterBack).toBeNull(); + }); + + it('does not touch state for non-tab-switch actions (passes the stock result through)', () => { + const original = buildOriginalRouter(); + const wrapped = buildWrappedRouter(); + const initialState = getInitialState(wrapped); + + const setParams = {type: 'SET_PARAMS', source: initialState.routes.at(0)?.key, payload: {params: {foo: 'bar'}}} as NavigationAction; + const originalNext = original.getStateForAction(initialState, setParams, CONFIG_OPTIONS); + const wrappedNext = wrapped.getStateForAction(initialState, setParams, CONFIG_OPTIONS); + + expect(wrappedNext).toEqual(originalNext); + }); +}); + +describe('getTabHistoryReplacementRouter', () => { + it('returns an override factory for the allowlisted distance navigator', () => { + expect(typeof getTabHistoryReplacementRouter(CONST.TAB.DISTANCE_REQUEST_TYPE)).toBe('function'); + }); + + it('returns undefined for navigators that are not allowlisted', () => { + expect(getTabHistoryReplacementRouter(CONST.TAB.IOU_REQUEST_TYPE)).toBeUndefined(); + expect(getTabHistoryReplacementRouter(CONST.TAB.SPLIT_EXPENSE_TAB_TYPE)).toBeUndefined(); + }); +}); From 22b230e056135a508e497d859f6a386dac528f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 8 Jun 2026 20:34:26 +0200 Subject: [PATCH 10/42] fix: restore input focus on tab-switch discard cancel and use getOdometerImageUri for image-change detection Ports two refinements from the earlier RHP-close work that were not carried into the browser-back rewrite: pass restoreLastInputFocus as the tab guard's onCancel so canceling the discard modal during a tab switch re-focuses the input, and compare odometer images via getOdometerImageUri in getHasUnsavedChanges. --- .../step/IOURequestStepDistanceOdometer.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index f5a30a1a1bc9..637f3f4a847c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -596,7 +596,9 @@ function IOURequestStepDistanceOdometer({ return false; } const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; - const hasImageChanges = transaction?.comment?.odometerStartImage !== initialStartImageRef.current || transaction?.comment?.odometerEndImage !== initialEndImageRef.current; + const hasImageChanges = + getOdometerImageUri(transaction?.comment?.odometerStartImage) !== getOdometerImageUri(initialStartImageRef.current) || + getOdometerImageUri(transaction?.comment?.odometerEndImage) !== getOdometerImageUri(initialEndImageRef.current); return hasReadingChanges || hasImageChanges; }; @@ -613,7 +615,13 @@ function IOURequestStepDistanceOdometer({ setFormError(''); }; - useRegisterDistanceTabGuard(CONST.TAB_REQUEST.DISTANCE_ODOMETER, getHasUnsavedChanges, handleTabSwitchDiscard); + const restoreLastInputFocus = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + lastFocusedInputRef.current?.focus(); + }); + }, []); + + useRegisterDistanceTabGuard(CONST.TAB_REQUEST.DISTANCE_ODOMETER, getHasUnsavedChanges, handleTabSwitchDiscard, restoreLastInputFocus); const handleSaveForLater = useCallback(async () => { shouldBypassDiscardConfirmationRef.current = true; @@ -665,11 +673,7 @@ function IOURequestStepDistanceOdometer({ {isStandaloneScreen && ( { - InteractionManager.runAfterInteractions(() => { - lastFocusedInputRef.current?.focus(); - }); - }} + onCancel={restoreLastInputFocus} onConfirm={ isEditingConfirmation ? async () => { From b089b7ce67ce3c72f2219b161b83bc6a1293e72f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 8 Jun 2026 21:13:07 +0200 Subject: [PATCH 11/42] refactor: tidy odometer tab-discard guards - centralize RN tab-switch action types (JUMP_TO/NAVIGATE_DEPRECATED) in CONST.NAVIGATION.ACTION_TYPE and add a test guarding the JUMP_TO coupling - reuse goForwardInBrowserHistory util in useDiscardChangesConfirmation instead of inline window.history.go(1) - document DistanceTabGuardContext as a single-active-guard registry --- src/CONST/index.ts | 4 ++++ src/hooks/useDiscardChangesConfirmation/index.ts | 4 ++-- src/libs/Navigation/getTabHistoryReplacementRouter.ts | 4 ++-- src/pages/iou/request/DistanceTabGuardContext.tsx | 4 ++++ .../Navigation/getTabHistoryReplacementRouter.test.ts | 8 +++++++- 5 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ac256093b7e8..c84cad36badf 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5898,6 +5898,10 @@ const CONST = { GO_BACK: 'GO_BACK', RESET: 'RESET', + /** React Navigation TabRouter action types that switch tabs; not exported as constants by the library */ + JUMP_TO: 'JUMP_TO', + NAVIGATE_DEPRECATED: 'NAVIGATE_DEPRECATED', + /** These action types are custom for RootNavigator */ DISMISS_MODAL: 'DISMISS_MODAL', REPLACE_FULLSCREEN_UNDER_RHP: 'REPLACE_FULLSCREEN_UNDER_RHP', diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 7d3b5d5112f5..f1d9f809c92a 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -6,6 +6,7 @@ import useBeforeRemove from '@hooks/useBeforeRemove'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import Log from '@libs/Log'; +import goForwardInBrowserHistory from '@libs/Navigation/goForwardInBrowserHistory'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -124,11 +125,10 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi return; } shouldNavigateBack.current = true; + goForwardInBrowserHistory(); if (closing) { - window.history.go(1); return; } - window.history.go(1); navigateAfterInteraction(showDiscardModal); }); diff --git a/src/libs/Navigation/getTabHistoryReplacementRouter.ts b/src/libs/Navigation/getTabHistoryReplacementRouter.ts index f310c5562bc3..b9981eccd779 100644 --- a/src/libs/Navigation/getTabHistoryReplacementRouter.ts +++ b/src/libs/Navigation/getTabHistoryReplacementRouter.ts @@ -18,8 +18,8 @@ type TabRouterOverrideFactory = (original: Rout const NAVIGATORS_WITH_REPLACED_TAB_HISTORY = new Set([CONST.TAB.DISTANCE_REQUEST_TYPE]); // Action types that switch tabs. `NAVIGATE` comes from URL/linking-driven switches; `JUMP_TO` / -// `NAVIGATE_DEPRECATED` are React Navigation's internal TabRouter action types (no exported constant to use) -const TAB_SWITCH_ACTION_TYPES = new Set([CONST.NAVIGATION.ACTION_TYPE.NAVIGATE, 'JUMP_TO', 'NAVIGATE_DEPRECATED']); +// `NAVIGATE_DEPRECATED` are React Navigation's internal TabRouter action types. +const TAB_SWITCH_ACTION_TYPES = new Set([CONST.NAVIGATION.ACTION_TYPE.NAVIGATE, CONST.NAVIGATION.ACTION_TYPE.JUMP_TO, CONST.NAVIGATION.ACTION_TYPE.NAVIGATE_DEPRECATED]); /** * Builds the `UNSTABLE_router` override that collapses the tab navigator's `history` down to just the diff --git a/src/pages/iou/request/DistanceTabGuardContext.tsx b/src/pages/iou/request/DistanceTabGuardContext.tsx index 7e757e0adaba..1abb558e5269 100644 --- a/src/pages/iou/request/DistanceTabGuardContext.tsx +++ b/src/pages/iou/request/DistanceTabGuardContext.tsx @@ -1,3 +1,7 @@ +/** + * Lets the active distance tab register discard callbacks that `DistanceRequestStartPage` reads to show the + * "Discard changes?" modal on tab switch / back. Only one guard is active at a time. + */ import {createContext, useContext, useEffect, useRef} from 'react'; type DistanceTabGuard = { diff --git a/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts b/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts index f121bfeba118..d46d8c19dd8c 100644 --- a/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts +++ b/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts @@ -82,11 +82,17 @@ describe('createTabHistoryReplacementRouter', () => { const wrapped = buildWrappedRouter(); const onOdometer = wrapped.getStateForAction(getInitialState(wrapped), TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; - const afterBack = wrapped.getStateForAction(onOdometer, {type: 'GO_BACK'} as NavigationAction, CONFIG_OPTIONS); + const afterBack = wrapped.getStateForAction(onOdometer, {type: CONST.NAVIGATION.ACTION_TYPE.GO_BACK} as NavigationAction, CONFIG_OPTIONS); expect(afterBack).toBeNull(); }); + it('still matches the React Navigation TabRouter action type for tab switches', () => { + // Guards the action-type coupling: the history clamp only fires for actions in TAB_SWITCH_ACTION_TYPES, + // so if a RN upgrade renamed JUMP_TO the clamp would silently stop working + expect(TabActions.jumpTo('odometer').type).toBe(CONST.NAVIGATION.ACTION_TYPE.JUMP_TO); + }); + it('does not touch state for non-tab-switch actions (passes the stock result through)', () => { const original = buildOriginalRouter(); const wrapped = buildWrappedRouter(); From 88c58682ff472db137cf306d4abfb07e571ab18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 9 Jun 2026 10:50:03 +0200 Subject: [PATCH 12/42] refactor: extract odometer resync decision logic into tested predicates Move the inline branching of IOURequestStepDistanceOdometer's "resync from transaction" effect into pure isExternalOdometerResync / shouldInitializeOdometerFromTransaction helpers and cover them with unit tests. The effect's side-effects and dependency array are unchanged - this is behavior-preserving, just making the subtle logic named and testable. --- .../IOURequestStepDistance/odometerResync.ts | 76 +++++++++++++++++++ .../step/IOURequestStepDistanceOdometer.tsx | 59 +++++++------- tests/unit/odometerResync.test.ts | 75 ++++++++++++++++++ 3 files changed, 177 insertions(+), 33 deletions(-) create mode 100644 src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts create mode 100644 tests/unit/odometerResync.test.ts diff --git a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts new file mode 100644 index 000000000000..d50b50e07af9 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts @@ -0,0 +1,76 @@ +/** + * Pure decision helpers for the "resync local odometer readings from the transaction" effect in + * `IOURequestStepDistanceOdometer` + * + * That effect keeps the local readings in sync with the transaction without clobbering in-progress typing, and + * slides the discard-changes baseline when the transaction is changed elsewhere (e.g. an edit saved from the + * confirmation step). The branching is subtle and easy to regress, so it lives here as small pure predicates + * that can be unit-tested in isolation from the effect's ref mutations + */ + +type OdometerResyncState = { + /** Transaction readings normalized to strings ('' when the transaction has no reading) */ + transactionStartValue: string; + transactionEndValue: string; + + /** Readings currently held in local component state */ + localStartValue: string; + localEndValue: string; + + /** Resolved image URIs from the transaction and from the on-mount baseline */ + transactionStartImageUri: string; + transactionEndImageUri: string; + baselineStartImageUri: string; + baselineEndImageUri: string; + + /** Whether the transaction carries any odometer reading */ + hasTransactionData: boolean; + + /** Whether local state already holds a reading */ + hasLocalState: boolean; + + /** Whether the on-mount initialization has already run */ + hasInitialized: boolean; + + /** Whether the user has typed changes that aren't written to the transaction yet */ + isUserTyping: boolean; + + /** Whether the screen is in edit mode */ + isEditing: boolean; +}; + +/** + * An "external resync" is when the transaction was changed somewhere else (e.g. an edit saved from the + * confirmation step) while nothing is being typed here - so the transaction becomes the new baseline. The + * typing guard prevents clobbering in-progress keystrokes that aren't in the transaction yet + */ +function isExternalOdometerResync(state: OdometerResyncState): boolean { + if (!state.hasTransactionData || !state.hasInitialized || state.isUserTyping) { + return false; + } + return ( + state.transactionStartValue !== state.localStartValue || + state.transactionEndValue !== state.localEndValue || + state.transactionStartImageUri !== state.baselineStartImageUri || + state.transactionEndImageUri !== state.baselineEndImageUri + ); +} + +/** + * Whether the local readings should be (re)initialized from the transaction: + * 1. first mount with transaction data, or + * 2. editing with transaction data and no local state yet, or + * 3. transaction has data but local state is empty (user navigated back from another page), or + * 4. an external resync arrived (the typing guard inside it avoids clobbering in-progress keystrokes) + */ +function shouldInitializeOdometerFromTransaction(state: OdometerResyncState, isExternalResync: boolean): boolean { + return ( + (!state.hasInitialized && state.hasTransactionData) || + (state.isEditing && state.hasTransactionData && !state.hasLocalState) || + (state.hasTransactionData && !state.hasLocalState && state.hasInitialized) || + isExternalResync + ); +} + +export {isExternalOdometerResync, shouldInitializeOdometerFromTransaction}; +export type {OdometerResyncState}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 637f3f4a847c..864de4caf063 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -65,6 +65,8 @@ import useOdometerImageHandlers from './IOURequestStepDistance/hooks/useOdometer import useOdometerNavigation from './IOURequestStepDistance/hooks/useOdometerNavigation'; import useOdometerReadingsState from './IOURequestStepDistance/hooks/useOdometerReadingsState'; import useOdometerTransactionBackup from './IOURequestStepDistance/hooks/useOdometerTransactionBackup'; +import type {OdometerResyncState} from './IOURequestStepDistance/odometerResync'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from './IOURequestStepDistance/odometerResync'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -226,42 +228,33 @@ function IOURequestStepDistanceOdometer({ useEffect(() => { const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; - - const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); - const hasLocalState = startReadingRef.current || endReadingRef.current; const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; - // External resync: txn was edited elsewhere with no in-flight typing here, making it the new baseline. - // Includes image URI diffs so an isEditingConfirmation Save that only touched the photos still resyncs. - const isExternalResync = - hasTransactionData && - hasInitializedRefs.current && - !userHasUnsavedTypingRef.current && - (startValue !== startReadingRef.current || - endValue !== endReadingRef.current || - getOdometerImageUri(currentTransaction?.comment?.odometerStartImage) !== getOdometerImageUri(initialStartImageRef.current) || - getOdometerImageUri(currentTransaction?.comment?.odometerEndImage) !== getOdometerImageUri(initialEndImageRef.current)); - - // Only initialize if: - // 1. We haven't initialized yet AND transaction has data, OR - // 2. We're editing and transaction has data (to load existing values), OR - // 3. Transaction has data but local state is empty (user navigated back from another page), OR - // 4. An external resync arrived - the typing-flag inside isExternalResync avoids clobbering - // in-progress keystrokes here. - const shouldInitialize = - (!hasInitializedRefs.current && hasTransactionData) || - (isEditing && hasTransactionData && !hasLocalState) || - (hasTransactionData && !hasLocalState && hasInitializedRefs.current) || - isExternalResync; - - if (shouldInitialize) { - if (startValue || endValue) { - setStartReading(startValue); - setEndReading(endValue); - startReadingRef.current = startValue; - endReadingRef.current = endValue; - } + const resyncState: OdometerResyncState = { + transactionStartValue: startValue, + transactionEndValue: endValue, + localStartValue: startReadingRef.current, + localEndValue: endReadingRef.current, + transactionStartImageUri: getOdometerImageUri(currentTransaction?.comment?.odometerStartImage), + transactionEndImageUri: getOdometerImageUri(currentTransaction?.comment?.odometerEndImage), + baselineStartImageUri: getOdometerImageUri(initialStartImageRef.current), + baselineEndImageUri: getOdometerImageUri(initialEndImageRef.current), + hasTransactionData: (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined), + hasLocalState: !!(startReadingRef.current || endReadingRef.current), + hasInitialized: hasInitializedRefs.current, + isUserTyping: userHasUnsavedTypingRef.current, + isEditing, + }; + + const isExternalResync = isExternalOdometerResync(resyncState); + + // Sync the local readings up from the transaction (but never clobber in-progress typing) + if (shouldInitializeOdometerFromTransaction(resyncState, isExternalResync) && (startValue || endValue)) { + setStartReading(startValue); + setEndReading(endValue); + startReadingRef.current = startValue; + endReadingRef.current = endValue; } // Slide the discard-changes baseline up so leaving doesn't flag the externally-saved value as unsaved diff --git a/tests/unit/odometerResync.test.ts b/tests/unit/odometerResync.test.ts new file mode 100644 index 000000000000..9d784df124d5 --- /dev/null +++ b/tests/unit/odometerResync.test.ts @@ -0,0 +1,75 @@ +import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; + +// A steady state: initialized, transaction and local readings/images all match, nothing being typed. +const STEADY_STATE: OdometerResyncState = { + transactionStartValue: '100', + transactionEndValue: '200', + localStartValue: '100', + localEndValue: '200', + transactionStartImageUri: 'start.jpg', + transactionEndImageUri: 'end.jpg', + baselineStartImageUri: 'start.jpg', + baselineEndImageUri: 'end.jpg', + hasTransactionData: true, + hasLocalState: true, + hasInitialized: true, + isUserTyping: false, + isEditing: false, +}; + +function buildState(overrides: Partial = {}): OdometerResyncState { + return {...STEADY_STATE, ...overrides}; +} + +describe('isExternalOdometerResync', () => { + it('is false in a steady state (everything matches)', () => { + expect(isExternalOdometerResync(STEADY_STATE)).toBe(false); + }); + + it('is true when a reading was changed externally', () => { + expect(isExternalOdometerResync(buildState({transactionStartValue: '150'}))).toBe(true); + }); + + it('is true when only an image was changed externally', () => { + expect(isExternalOdometerResync(buildState({transactionStartImageUri: 'new-start.jpg'}))).toBe(true); + }); + + it('is false before on-mount initialization, even if values differ', () => { + expect(isExternalOdometerResync(buildState({hasInitialized: false, transactionStartValue: '150'}))).toBe(false); + }); + + it('is false while the user is typing, even if values differ', () => { + expect(isExternalOdometerResync(buildState({isUserTyping: true, transactionStartValue: '150'}))).toBe(false); + }); + + it('is false when the transaction has no reading data', () => { + expect(isExternalOdometerResync(buildState({hasTransactionData: false, transactionStartValue: '150'}))).toBe(false); + }); +}); + +describe('shouldInitializeOdometerFromTransaction', () => { + it('initializes on first mount when the transaction has data', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasInitialized: false}), false)).toBe(true); + }); + + it('initializes when editing with transaction data and no local state yet', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({isEditing: true, hasLocalState: false}), false)).toBe(true); + }); + + it('initializes when transaction has data but local state is empty (navigated back)', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasLocalState: false}), false)).toBe(true); + }); + + it('initializes when an external resync arrived', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasTransactionData: false}), true)).toBe(true); + }); + + it('does not initialize in a steady state', () => { + expect(shouldInitializeOdometerFromTransaction(STEADY_STATE, false)).toBe(false); + }); + + it('does not initialize when the transaction has no data and there is no resync', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasTransactionData: false, hasLocalState: false}), false)).toBe(false); + }); +}); From ebd7849f4d4374bb91e1f237d035b4fd59b1740d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 12 Jun 2026 12:37:16 +0200 Subject: [PATCH 13/42] fix(odometer): remove duplicate edit-from-confirmation backup effect causing double-restore --- config/eslint/eslint.seatbelt.tsv | 2 + .../step/IOURequestStepDistanceOdometer.tsx | 22 +- tests/actions/TransactionEditTest.ts | 55 ++- ...URequestStepDistanceOdometerBackupTest.tsx | 383 ++++++++++++++++++ .../useOdometerTransactionBackup.test.ts | 98 +++++ 5 files changed, 538 insertions(+), 22 deletions(-) create mode 100644 tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx create mode 100644 tests/unit/hooks/useOdometerTransactionBackup.test.ts diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index dd8ac0e99af4..269f572cf1e8 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1657,6 +1657,7 @@ "../../tests/ui/ForYouSectionTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/ui/IOURequestStartPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/ui/IOURequestStepAmountDraftTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../tests/ui/IOURequestStepDistanceTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/ui/IOURequestStepScanTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/ui/InitialSettingsPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1950,6 +1951,7 @@ "../../tests/unit/hooks/useNonPersonalCardList.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/unit/hooks/useOdometerReadingsState.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/hooks/useOdometerReceiptStitcherTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 +"../../tests/unit/hooks/useOdometerTransactionBackup.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 16 "../../tests/unit/hooks/usePendingConciergeResponse.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../tests/unit/hooks/useReceiptTraining.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 7cbd12116f85..10062e1f13fa 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -41,7 +41,7 @@ import { saveOdometerDraft, setMoneyRequestOdometerReading, } from '@libs/actions/OdometerTransactionUtils'; -import {createBackupTransaction, removeBackupTransactionWithImageCleanup, restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; +import {restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; @@ -302,26 +302,6 @@ function IOURequestStepDistanceOdometer({ personalOutputCurrency: personalPolicy?.outputCurrency, }); - useEffect(() => { - if (!isEditingConfirmation) { - return () => {}; - } - createBackupTransaction(transaction, isTransactionDraft, true); - - return () => { - if (backupHandledManually.current) { - return; - } - if (didSaveEditingConfirmationRef.current) { - removeBackupTransactionWithImageCleanup(transactionID, isTransactionDraft); - return; - } - restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); - }; - // We only want to create the backup once on mount and restore/remove it on unmount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - // Calculate total distance - updated live after every input change const totalDistance = (() => { const start = parseFloat(DistanceRequestUtils.normalizeOdometerText(startReading, fromLocaleDigit)); diff --git a/tests/actions/TransactionEditTest.ts b/tests/actions/TransactionEditTest.ts index b54cd376f636..c639609bf7f6 100644 --- a/tests/actions/TransactionEditTest.ts +++ b/tests/actions/TransactionEditTest.ts @@ -1,6 +1,11 @@ import Onyx from 'react-native-onyx'; import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; -import {createBackupTransaction, removeDraftTransactionsByIDs, restoreOriginalTransactionFromBackup} from '@libs/actions/TransactionEdit'; +import { + createBackupTransaction, + removeDraftTransactionsByIDs, + restoreOriginalTransactionFromBackup, + restoreOriginalTransactionFromBackupWithImageCleanup, +} from '@libs/actions/TransactionEdit'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import CONST from '@src/CONST'; import * as SequentialQueue from '@src/libs/Network/SequentialQueue'; @@ -130,6 +135,54 @@ describe('actions/TransactionEdit', () => { expect(transactions).toBeUndefined(); }); }); + + describe('restoreOriginalTransactionFromBackupWithImageCleanup', () => { + const transactionOriginal = createRandomTransaction(1); + + it('should restore the transaction from backup and remove the backup', async () => { + const transactionBackup = {...transactionOriginal, amount: 200}; + const isDraft = false; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`, {...transactionOriginal, amount: 100}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`, transactionBackup); + await waitForBatchedUpdates(); + + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionOriginal.transactionID, isDraft); + await waitForBatchedUpdates(); + + const restoredTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`); + const backupTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`); + + expect(restoredTransaction).not.toBeNull(); + expect(restoredTransaction?.amount).toBe(transactionBackup.amount); + expect(backupTransaction).toBeUndefined(); + }); + + // Regression guard for the duplicate edit-from-confirmation backup effect: the first restore + // removes the backup, so a second restore reads a missing backup and nulls the transaction. + // This is why the cleanup must run exactly once (see IOURequestStepDistanceOdometer) + it('should null the transaction on a second restore after the backup is gone', async () => { + const transactionBackup = {...transactionOriginal, amount: 200}; + const isDraft = false; + + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`, {...transactionOriginal, amount: 100}); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`, transactionBackup); + await waitForBatchedUpdates(); + + // First restore: transaction is restored from backup, backup is removed + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionOriginal.transactionID, isDraft); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`)).not.toBeNull(); + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionOriginal.transactionID}`)).toBeUndefined(); + + // Second restore: backup is gone, so the transaction is wiped + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionOriginal.transactionID, isDraft); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionOriginal.transactionID}`)).toBeUndefined(); + }); + }); }); describe('removeDraftTransactionsByIDs', () => { diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx new file mode 100644 index 000000000000..2e4dd8d5e5c1 --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -0,0 +1,383 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +import {act, render} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import * as TransactionEdit from '@libs/actions/TransactionEdit'; +import DistanceTabGuardContext from '@pages/iou/request/DistanceTabGuardContext'; +import type {DistanceTabGuard, RegisterDistanceTabGuard} from '@pages/iou/request/DistanceTabGuardContext'; +import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {Report, Transaction} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; +import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => ({ + default: jest.fn(), + MarkerView: jest.fn(), + setAccessToken: jest.fn(), +})); + +jest.mock('@components/LocaleContextProvider', () => { + const React2 = require('react'); + const defaultContextValue = { + translate: (path: string) => path, + numberFormat: (number: number) => String(number), + getLocalDateFromDatetime: () => new Date(), + datetimeToRelative: () => '', + datetimeToCalendarTime: () => '', + formatPhoneNumber: (phone: string) => phone, + toLocaleDigit: (digit: string) => digit, + toLocaleOrdinal: (number: number) => String(number), + fromLocaleDigit: (localeDigit: string) => localeDigit, + localeCompare: (a: string, b: string) => a.localeCompare(b), + formatTravelDate: () => '', + preferredLocale: 'en', + }; + const LocaleContext = React2.createContext(defaultContextValue); + return { + LocaleContext, + LocaleContextProvider: ({children}: {children: React.ReactNode}) => React2.createElement(LocaleContext.Provider, {value: defaultContextValue}, children), + }; +}); + +// Keep the real backup/restore logic but spy on the restore so we can assert it runs exactly once on unmount. +// The duplicate inline effect (now removed) made it run twice, which nulled the transaction on the second pass +jest.mock('@libs/actions/TransactionEdit', () => { + const actual = jest.requireActual('@libs/actions/TransactionEdit'); + return { + ...actual, + restoreOriginalTransactionFromBackupWithImageCleanup: jest.fn(actual.restoreOriginalTransactionFromBackupWithImageCleanup), + }; +}); + +jest.mock('@libs/actions/MapboxToken', () => ({ + init: jest.fn(), + stop: jest.fn(), +})); + +jest.mock('@components/ProductTrainingContext', () => ({ + useProductTrainingContext: () => [false], +})); + +jest.mock('@hooks/useShowNotFoundPageInIOUStep', () => () => false); +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + __esModule: true, + default: () => ({didScreenTransitionEnd: true}), +})); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance_Odometer', params: {}})), + getState: jest.fn(() => ({})), +})); + +jest.mock('@libs/Navigation/Navigation', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance_Odometer', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), + dismissModalWithReport: jest.fn(), + navigationRef: mockRef, + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(() => undefined), + removeScreenByKey: jest.fn(), + }; +}); + +jest.mock('@react-navigation/native', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance_Odometer', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + createNavigationContainerRef: jest.fn(() => mockRef), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + useRoute: jest.fn(), + }; +}); + +const ACCOUNT_ID = 1; +const ACCOUNT_LOGIN = 'test@user.com'; +const REPORT_ID = 'report-odometer-backup-1'; +const TRANSACTION_ID = 'txn-odometer-backup-1'; +const ODOMETER_START = 100; +const ODOMETER_END = 300; + +function createTestReport(): Report { + return { + reportID: REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isPinned: false, + lastVisibleActionCreated: '', + lastReadTime: '', + participants: { + [ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}, + }, + }; +} + +// A populated odometer expense, as it exists when the user reaches the confirmation step and taps "Distance" +function createOdometerTransaction(): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + odometerStart: ODOMETER_START, + odometerEnd: ODOMETER_END, + customUnit: { + customUnitID: 'test-unit-id', + customUnitRateID: 'test-rate-id', + name: 'Distance', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + } as unknown as Transaction; +} + +// Edit-from-confirmation entry: route name !== DISTANCE_CREATE and action !== EDIT -> `isEditingConfirmation` is true +function renderEditFromConfirmationOdometer() { + return render( + + + + + , + ); +} + +describe('IOURequestStepDistanceOdometer - edit-from-confirmation backup is restored exactly once on plain back', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + }); + + // Regression: the edit-from-confirmation screen once ran two identical backup-restore effects. On a plain "back" + // the first restored + deleted the backup, the second found it missing and nulled the transaction. The + // `useOdometerTransactionBackup` hook must be the sole owner of this lifecycle + it('restores the transaction once and does not wipe it when unmounting without saving', async () => { + const transaction = createOdometerTransaction(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`, transaction); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + const {unmount} = renderEditFromConfirmationOdometer(); + await waitForBatchedUpdatesWithAct(); + + // Plain back / unmount without setting didSaveEditingConfirmationRef or backupHandledManually + unmount(); + await waitForBatchedUpdatesWithAct(); + await waitForBatchedUpdatesWithAct(); + + // The backup restore must run exactly once. With the duplicate inline effect it ran twice (the second + // restore nulled the transaction). This assertion is 2 on the buggy code + expect(TransactionEdit.restoreOriginalTransactionFromBackupWithImageCleanup as jest.Mock).toHaveBeenCalledTimes(1); + + // The expense must survive the back navigation: the draft transaction is restored from the backup, not nulled + const restoredTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(restoredTransaction).not.toBeNull(); + expect(restoredTransaction).not.toBeUndefined(); + expect(restoredTransaction?.comment?.odometerStart).toBe(ODOMETER_START); + expect(restoredTransaction?.comment?.odometerEnd).toBe(ODOMETER_END); + }); +}); + +// Integration tests for the discard guard: they capture the real closure registered via +// useRegisterDistanceTabGuard (through DistanceTabGuardContext) and call its getHasUnsavedChanges(). +// +// Setup is a save-for-later draft that holds the readings; the user then edits the image. The tests assert both +// directions: +// - a real image edit (add / swap / remove) -> getHasUnsavedChanges() is true (the discard modal must fire) +// - re-entering with nothing changed -> getHasUnsavedChanges() is false (no false prompt) +// +// The first case is the false-negative being fixed: the draft check is presence-only, so it missed an image that +// moved ahead of the draft. Unlike the unit tests, these run the whole path (resync effect + image baseline + +// getOdometerHasUnsavedChanges), not just the pure function. +describe('IOURequestStepDistanceOdometer - discard guard detects user image changes with an active save-for-later draft', () => { + const START_IMAGE_A: FileObject = {uri: 'a.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const START_IMAGE_B: FileObject = {uri: 'b.jpg', name: 'b.jpg', type: 'image/jpeg', size: 5678}; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + }); + + function renderWithCapturedGuard(register: RegisterDistanceTabGuard) { + return render( + + + + + + + , + ); + } + + // Seeds the edit-from-confirmation flow with an active save-for-later readings draft and renders, returning the + // captured tab guard. `startImage` optionally seeds an image already present at mount (for swap/remove) + async function setupAndRender(startImage?: FileObject): Promise<{getHasUnsavedChanges: () => boolean; transaction: Transaction}> { + const transaction = createOdometerTransaction(); + if (startImage) { + transaction.comment = {...transaction.comment, odometerStartImage: startImage}; + } + let capturedGuard: DistanceTabGuard | undefined; + const register: RegisterDistanceTabGuard = (guard) => { + capturedGuard = guard; + return () => {}; + }; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`, transaction); + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, {odometerStartReading: ODOMETER_START, odometerEndReading: ODOMETER_END}); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + renderWithCapturedGuard(register); + await waitForBatchedUpdatesWithAct(); + + return {getHasUnsavedChanges: () => capturedGuard?.getHasUnsavedChanges() ?? false, transaction}; + } + + it('flags an added image as unsaved (the false-negative being fixed)', async () => { + const {getHasUnsavedChanges, transaction} = await setupAndRender(); + + // Baseline captured from a transaction with no image; nothing changed yet + expect(getHasUnsavedChanges()).toBe(false); + + await act(async () => { + setMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, START_IMAGE_A, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(true); + }); + + it('flags a swapped image as unsaved', async () => { + const {getHasUnsavedChanges, transaction} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + + await act(async () => { + setMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, START_IMAGE_B, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(true); + }); + + it('flags a removed image as unsaved', async () => { + const {getHasUnsavedChanges, transaction} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + + await act(async () => { + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(true); + }); + + it('stays silent when the screen is re-entered with no image change', async () => { + const {getHasUnsavedChanges} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + }); + + // A NON-user image URI change (blob re-mint on reload, external save) keeps the file name + size and only changes + // the uri, so its re-mint-invariant identity (getOdometerImageIdentity = name|size) is unchanged and the guard + // stays silent - no "user edited" tracking needed + it('stays silent for a non-user image URI change (e.g. blob re-mint) because the identity is invariant', async () => { + const {getHasUnsavedChanges} = await setupAndRender(START_IMAGE_A); + + expect(getHasUnsavedChanges()).toBe(false); + + // Change the image URI WITHOUT going through the user-edit actions -> no marker is set + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + comment: {odometerStartImage: {uri: 'a-reminted.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + expect(getHasUnsavedChanges()).toBe(false); + }); +}); diff --git a/tests/unit/hooks/useOdometerTransactionBackup.test.ts b/tests/unit/hooks/useOdometerTransactionBackup.test.ts new file mode 100644 index 000000000000..bec40a211bbc --- /dev/null +++ b/tests/unit/hooks/useOdometerTransactionBackup.test.ts @@ -0,0 +1,98 @@ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useOdometerTransactionBackup from '@pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerTransactionBackup'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes from '@src/types/onyx'; +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TRANSACTION_ID = 'odometer-backup-test'; + +const buildOdometerTransaction = (overrides: Partial = {}): OnyxTypes.Transaction => + ({ + transactionID: TRANSACTION_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + amount: 1234, + merchant: '5 mi', + comment: { + odometerStart: 100, + odometerEnd: 250, + ...overrides, + }, + }) as unknown as OnyxTypes.Transaction; + +type Params = Parameters[0]; + +const renderBackupHook = (overrides: Partial = {}) => { + const original = buildOdometerTransaction(); + const params: Params = { + transaction: original, + isEditingConfirmation: true, + isTransactionDraft: false, + transactionID: TRANSACTION_ID, + didSaveEditingConfirmationRef: {current: false}, + backupHandledManuallyRef: {current: false}, + ...overrides, + }; + return {original, ...renderHook(() => useOdometerTransactionBackup(params)), params}; +}; + +describe('useOdometerTransactionBackup', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + it('edit-from-confirmation header-back restores once and does not null the transaction', async () => { + const original = buildOdometerTransaction(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, original); + await waitForBatchedUpdates(); + + // Mount in edit-from-confirmation mode -> a backup of the original is created + const {unmount} = renderBackupHook({transaction: original}); + await waitForBatchedUpdates(); + + const backupAfterMount = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`); + expect(backupAfterMount).toEqual(original); + + // Simulate the user editing the live transaction on the odometer screen + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, {...original, comment: {odometerStart: 100, odometerEnd: 999}, merchant: '899 mi', amount: 99999}); + await waitForBatchedUpdates(); + + // Header back with no save -> the single cleanup restores from the backup + unmount(); + await waitForBatchedUpdates(); + + const restored = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`); + const backupAfterUnmount = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`); + + // The transaction is restored exactly once: it equals the original and was NOT nulled + // (a second restore would read the now-removed backup and wipe the transaction) + expect(restored).toEqual(original); + expect(restored).not.toBeNull(); + expect(restored).not.toBeUndefined(); + expect(backupAfterUnmount).toBeUndefined(); + }); + + it('does not back up or restore when not editing from confirmation', async () => { + const original = buildOdometerTransaction(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, original); + await waitForBatchedUpdates(); + + const {unmount} = renderBackupHook({transaction: original, isEditingConfirmation: false}); + await waitForBatchedUpdates(); + + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${TRANSACTION_ID}`)).toBeUndefined(); + + unmount(); + await waitForBatchedUpdates(); + + // The live transaction is untouched because no backup flow ran + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`)).toEqual(original); + }); +}); From 3a95d0f6c582c6ca3116eb5afc39368959f16bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 12 Jun 2026 12:39:22 +0200 Subject: [PATCH 14/42] fix(odometer): make discard-changes diff re-mint-invariant via image identity --- config/eslint/eslint.seatbelt.tsv | 2 + src/libs/OdometerImageUtils.ts | 27 ++- src/libs/actions/OdometerTransactionUtils.ts | 71 ++++++- .../step/IOURequestStepDistanceOdometer.tsx | 73 +++---- tests/actions/OdometerTransactionUtilsTest.ts | 195 +++++++++++++++++- 5 files changed, 308 insertions(+), 60 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 269f572cf1e8..da013c476183 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1585,6 +1585,8 @@ "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/actions/MergeTransactionTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 +"../../tests/actions/OdometerTransactionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/actions/OdometerTransactionUtilsTest.ts" "import/consistent-type-specifier-style" 1 "../../tests/actions/OnyxUpdateManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/actions/PersonalDetailsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 "../../tests/actions/PolicyCategoryTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index 940036384f22..f4da9c583f67 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -9,18 +9,31 @@ function getOdometerImageName(image: FileObject | string | null | undefined): st return typeof image === 'string' ? (image.split('/').pop() ?? '') : (image?.name ?? ''); } +/** + * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. + * + * A re-mint (base64 -> blob via `deserializeOdometerDraftImage` on resume/reload, or a blob revoke/recreate) + * keeps the file `name` and `size` but produces a fresh blob `uri`. So comparing `name|size` stays stable + * across a re-mint while a different file (different name and/or size) reads as a change. Native images are + * `file://` uri strings that never re-mint, so the string itself is already a stable identity + */ +function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { + if (!image) { + return ''; + } + if (typeof image === 'string') { + return image; + } + return `${image.name ?? ''}|${image.size ?? ''}`; +} + function getOdometerImageType(image: FileObject | string | null | undefined): string | undefined { return typeof image === 'string' ? getMimeTypeFromUri(image) : (image?.type ?? getMimeTypeFromUri(image?.uri ?? '')); } /** * Revokes a blob URL previously associated with an odometer image, but only when - * the image has actually changed (i.e. the old URL differs from the new one). - * - * Skips revocation when: - * - The `URL` API is not available (non-browser environments / native) - * - The URI is not a blob: URL (e.g. file:// on native, https:// for uploaded images) - * - The old and new URIs are identical (image was not replaced) + * the image has actually changed (i.e. the old URL differs from the new one) */ function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { if (typeof URL === 'undefined') { @@ -38,5 +51,5 @@ function revokeOdometerImageUri(image: FileObject | string | null | undefined, n URL.revokeObjectURL(currentUri); } -export {getOdometerImageUri, getOdometerImageName, getOdometerImageType}; +export {getOdometerImageUri, getOdometerImageName, getOdometerImageType, getOdometerImageIdentity}; export default revokeOdometerImageUri; diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index 231c660f882c..6f42457ff70f 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -21,7 +21,7 @@ type SaveOdometerDraftParams = { }; /** - * Set the odometer readings for a transaction + * Set the odometer readings for a transaction. */ function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { @@ -207,20 +207,69 @@ function hydrateOdometerDraftIntoTransaction(transactionID: string, odometerDraf } /** - * True when an ODOMETER_DRAFT exists but the active transaction comment hasn't yet been hydrated - * from it. Used to defer baseline snapshots that would otherwise treat post-hydration values as - * unsaved changes. + * True when an ODOMETER_DRAFT exists but the transaction comment hasn't received it yet (hydration pending) - + * used to defer the baseline snapshot and gate the hydration effect */ function isOdometerDraftPendingHydration(odometerDraft: OnyxEntry, comment: Partial | undefined): boolean { if (!odometerDraft) { return false; } - return ( - (odometerDraft.odometerStartReading ?? null) !== (comment?.odometerStart ?? null) || - (odometerDraft.odometerEndReading ?? null) !== (comment?.odometerEnd ?? null) || - !!odometerDraft.odometerStartImage !== !!comment?.odometerStartImage || - !!odometerDraft.odometerEndImage !== !!comment?.odometerEndImage - ); + const startPending = odometerDraft.odometerStartReading !== undefined && (comment?.odometerStart ?? null) === null; + const endPending = odometerDraft.odometerEndReading !== undefined && (comment?.odometerEnd ?? null) === null; + const draftCarriesReadings = odometerDraft.odometerStartReading !== undefined || odometerDraft.odometerEndReading !== undefined; + const startImagePending = !draftCarriesReadings && !!odometerDraft.odometerStartImage && !comment?.odometerStartImage; + const endImagePending = !draftCarriesReadings && !!odometerDraft.odometerEndImage && !comment?.odometerEndImage; + return startPending || endPending || startImagePending || endImagePending; +} + +type OdometerUnsavedChangesState = { + /** Discard guard active: focused && !editing && no bypass/save/manual-backup flags. */ + isGuardActive: boolean; + + /** Whether the reading text inputs hold values not yet written to the transaction (mid-edit typing). */ + isUserTyping: boolean; + + /** The active save-for-later draft, if any. */ + odometerDraft: OnyxEntry; + + /** The comment of currentTransaction (split draft when editing a split, else the transaction) - used ONLY for the directional hydration check. */ + currentComment: Partial | undefined; + + /** Re-mint-invariant start/end image identities (getOdometerImageIdentity) from the live transaction comment. */ + transactionStartImageUri: string; + transactionEndImageUri: string; + + /** Re-mint-invariant start/end image identities captured in the on-mount baseline refs. */ + baselineStartImageUri: string; + baselineEndImageUri: string; + + /** Whether the readings differ from the on-mount baseline. */ + hasReadingChanges: boolean; +}; + +/** + * Decide whether the odometer screen has unsaved changes that warrant a "Discard changes?" prompt. + * + * The image guard is load-bearing: isOdometerDraftPendingHydration is directional (reports a transaction MISSING + * what the draft holds, never one AHEAD of it), so a genuine image add/swap reads as "not pending" and the draft + * clause would wrongly suppress it - hence that clause must not suppress when hasImageChanges is set. The identity + * is re-mint-invariant (name|size), so a non-user blob re-mint doesn't register. The typing guard does the same for readings + */ +function getOdometerHasUnsavedChanges(state: OdometerUnsavedChangesState): boolean { + if (!state.isGuardActive) { + return false; + } + const hasImageChanges = state.transactionStartImageUri !== state.baselineStartImageUri || state.transactionEndImageUri !== state.baselineEndImageUri; + // Suppress only when the transaction still EQUALS the draft (spurious baseline drift, not a genuine edit). + // isOdometerDraftPendingHydration is directional, so it can't catch a transaction that moved AHEAD of the draft + // (changed reading + Next, no re-save) - this reading-equality check and the typing guard do + const draftReadingsMatchTransaction = + (state.odometerDraft?.odometerStartReading ?? null) === (state.currentComment?.odometerStart ?? null) && + (state.odometerDraft?.odometerEndReading ?? null) === (state.currentComment?.odometerEnd ?? null); + if (!state.isUserTyping && !hasImageChanges && !!state.odometerDraft && draftReadingsMatchTransaction && !isOdometerDraftPendingHydration(state.odometerDraft, state.currentComment)) { + return false; + } + return state.hasReadingChanges || hasImageChanges; } export { @@ -231,5 +280,7 @@ export { saveOdometerDraft, hydrateOdometerDraftIntoTransaction, isOdometerDraftPendingHydration, + getOdometerHasUnsavedChanges, }; +export type {OdometerUnsavedChangesState}; export default clearOdometerDraftTransactionState; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 10062e1f13fa..8824d4e0b467 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -34,13 +34,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setMoneyRequestDistance} from '@libs/actions/IOU/MoneyRequest'; import {setDraftSplitTransaction} from '@libs/actions/IOU/Split'; import {updateMoneyRequestDistance} from '@libs/actions/IOU/UpdateMoneyRequest'; -import { - clearOdometerDraft, - isOdometerDraftPendingHydration, - removeMoneyRequestOdometerImage, - saveOdometerDraft, - setMoneyRequestOdometerReading, -} from '@libs/actions/OdometerTransactionUtils'; +import {clearOdometerDraft, getOdometerHasUnsavedChanges, removeMoneyRequestOdometerImage, saveOdometerDraft, setMoneyRequestOdometerReading} from '@libs/actions/OdometerTransactionUtils'; import {restoreOriginalTransactionFromBackupWithImageCleanup} from '@libs/actions/TransactionEdit'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; @@ -48,7 +42,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; import {startSpan} from '@libs/telemetry/activeSpans'; @@ -86,7 +80,7 @@ type StandaloneDiscardGuardProps = { }; /** - * A component (not an inline hook) so the discard hook runs only on the standalone screen — in the + * A component (not an inline hook) so the discard hook runs only on the standalone screen - in the * `DISTANCE_CREATE` tab flow `DistanceRequestStartPage` owns discard, and running it here too would * register a second `beforeRemove` listener and show the discard modal twice. */ @@ -236,10 +230,10 @@ function IOURequestStepDistanceOdometer({ transactionEndValue: endValue, localStartValue: startReadingRef.current, localEndValue: endReadingRef.current, - transactionStartImageUri: getOdometerImageUri(currentTransaction?.comment?.odometerStartImage), - transactionEndImageUri: getOdometerImageUri(currentTransaction?.comment?.odometerEndImage), - baselineStartImageUri: getOdometerImageUri(initialStartImageRef.current), - baselineEndImageUri: getOdometerImageUri(initialEndImageRef.current), + transactionStartImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerStartImage), + transactionEndImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerEndImage), + baselineStartImageUri: getOdometerImageIdentity(initialStartImageRef.current), + baselineEndImageUri: getOdometerImageIdentity(initialEndImageRef.current), hasTransactionData: (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined), hasLocalState: !!(startReadingRef.current || endReadingRef.current), hasInitialized: hasInitializedRefs.current, @@ -257,12 +251,13 @@ function IOURequestStepDistanceOdometer({ endReadingRef.current = endValue; } - // Slide the discard-changes baseline up so leaving doesn't flag the externally-saved value as unsaved + // Slide the readings baseline up on a non-user change (draft hydration, an external save from confirmation) + // so leaving doesn't flag the externally-saved value as unsaved; the typing guard in isExternalOdometerResync + // protects in-progress keystrokes. Images are NOT slid: their diff uses a re-mint-invariant identity, so a + // re-mint never registers (nothing to absorb) and sliding would wrongly absorb a genuine swap if (isExternalResync) { initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; - initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; - initialEndImageRef.current = currentTransaction?.comment?.odometerEndImage; } // The refs and setters destructured from `useOdometerReadingsState` are stable; we intentionally omit them // to keep this effect driven only by transaction changes. @@ -346,11 +341,6 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); - // Per-keystroke validation: enforce format constraints and cap the max value. - // The max-value check allows edits that *reduce* the value (e.g. backspacing - // a legacy over-max reading) but rejects keystrokes that would increase - // beyond ODOMETER_MAX_VALUE. Submit-time validation in handleNext is the - // final safety net. const isOdometerInputValid = (text: string, previousText: string): boolean => { if (!text) { return true; @@ -366,7 +356,7 @@ function IOURequestStepDistanceOdometer({ const value = parseFloat(stripped); // Allow edits that reduce the value (e.g. backspacing a legacy over-max reading), - // but reject keystrokes that would increase beyond the max. + // but reject keystrokes that would increase beyond the max if (!Number.isNaN(value) && value > CONST.IOU.ODOMETER_MAX_VALUE) { const previousValue = parseFloat(DistanceRequestUtils.normalizeOdometerText(previousText, fromLocaleDigit)); if (Number.isNaN(previousValue) || value >= previousValue) { @@ -502,7 +492,7 @@ function IOURequestStepDistanceOdometer({ } if (shouldSkipConfirmation) { - // Skip-confirmation submit navigates away and should never be blocked by discard modal. + // Skip-confirmation submit navigates away and should never be blocked by discard modal shouldBypassDiscardConfirmationRef.current = true; } @@ -561,20 +551,24 @@ function IOURequestStepDistanceOdometer({ navigateToNextPage(); }; - const getHasUnsavedChanges = () => { - if (!isFocused || isEditing || shouldBypassDiscardConfirmationRef.current || didSaveEditingConfirmationRef.current || backupHandledManually.current) { - return false; - } - // Draft matching the transaction means changes are persisted; the typing guard prevents suppressing the modal mid-edit (typed values aren't in the transaction yet). - if (!userHasUnsavedTypingRef.current && !!odometerDraft && !isOdometerDraftPendingHydration(odometerDraft, currentTransaction?.comment)) { - return false; - } - const hasReadingChanges = startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current; - const hasImageChanges = - getOdometerImageUri(transaction?.comment?.odometerStartImage) !== getOdometerImageUri(initialStartImageRef.current) || - getOdometerImageUri(transaction?.comment?.odometerEndImage) !== getOdometerImageUri(initialEndImageRef.current); - return hasReadingChanges || hasImageChanges; - }; + const getHasUnsavedChanges = () => + getOdometerHasUnsavedChanges({ + isGuardActive: + hasInitializedRefs.current && + isFocused && + !isEditing && + !shouldBypassDiscardConfirmationRef.current && + !didSaveEditingConfirmationRef.current && + !backupHandledManually.current, + isUserTyping: userHasUnsavedTypingRef.current, + odometerDraft, + currentComment: currentTransaction?.comment, + transactionStartImageUri: getOdometerImageIdentity(transaction?.comment?.odometerStartImage), + transactionEndImageUri: getOdometerImageIdentity(transaction?.comment?.odometerEndImage), + baselineStartImageUri: getOdometerImageIdentity(initialStartImageRef.current), + baselineEndImageUri: getOdometerImageIdentity(initialEndImageRef.current), + hasReadingChanges: startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current, + }); const handleTabSwitchDiscard = async () => { if (isEditingConfirmation) { @@ -630,9 +624,8 @@ function IOURequestStepDistanceOdometer({ Navigation.closeRHPFlow(); }, [fromLocaleDigit, startReading, endReading, odometerStartImage, odometerEndImage, translate, setFormError]); - // The discard hook runs only on the standalone screen (edit / edit-from-confirmation). In the - // `DISTANCE_CREATE` tab flow `DistanceRequestStartPage` owns discard; running it here too would show - // the discard modal twice on browser back. Rendered as a child because hooks can't be conditional. + // Standalone screen only (edit / edit-from-confirmation); in the `DISTANCE_CREATE` tab flow `DistanceRequestStartPage` + // owns discard, so running it here too would show the modal twice. Rendered as a child since hooks can't be conditional const isStandaloneScreen = routeName !== SCREENS.MONEY_REQUEST.DISTANCE_CREATE; return ( diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 6bd75c60e426..5fcb1f2250ec 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -2,14 +2,16 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import '@libs/actions/IOU/MoneyRequest'; -import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import {getOdometerHasUnsavedChanges, isOdometerDraftPendingHydration, removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import type {OdometerUnsavedChangesState} from '@libs/actions/OdometerTransactionUtils'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy} from '@src/types/onyx'; +import type {OdometerDraft, Policy} from '@src/types/onyx'; import type Transaction from '@src/types/onyx/Transaction'; import currencyList from '../unit/currencyList.json'; import createRandomTransaction from '../utils/collections/transaction'; @@ -56,7 +58,7 @@ jest.mock('@libs/Navigation/helpers/isSearchTopmostFullScreenRoute', () => jest. jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => jest.fn()); // In production, requestMoney defers its API.write() call until the target screen's // content lays out (or a safety timeout fires). In tests there is no target component -// to flush the deferred write, so we bypass the deferral by executing the callback immediately. +// to flush the deferred write, so we bypass the deferral by executing the callback immediately jest.mock('@libs/deferredLayoutWrite', () => ({ registerDeferredWrite: (_key: string, callback: () => void) => callback(), flushDeferredWrite: jest.fn(), @@ -207,4 +209,191 @@ describe('actions/OdometerTransactionUtils', () => { expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); }); }); + + describe('isOdometerDraftPendingHydration (directional)', () => { + it('returns false when there is no draft (no save-for-later flow)', () => { + expect(isOdometerDraftPendingHydration(undefined, {odometerStart: 120, odometerEnd: 300})).toBe(false); + }); + + it('is pending while the transaction is still MISSING readings the draft carries (the hydration window)', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250}; + expect(isOdometerDraftPendingHydration(draft, undefined)).toBe(true); + expect(isOdometerDraftPendingHydration(draft, {})).toBe(true); + // Partially hydrated (start landed, end not yet) is still pending. + expect(isOdometerDraftPendingHydration(draft, {odometerStart: 100})).toBe(true); + }); + + // After Next the transaction holds new readings but the draft still holds the old ones (Next doesn't write + // the draft). A staler-than-transaction draft must read as NOT pending, else the baseline defers forever + it('is NOT pending once the transaction holds the readings, even if the draft is now staler', () => { + const staleDraft = {odometerStartReading: 100, odometerEndReading: 250}; + const newerComment = {odometerStart: 120, odometerEnd: 300}; + expect(isOdometerDraftPendingHydration(staleDraft, newerComment)).toBe(false); + }); + + // A readings-bearing draft hydrates atomically, so once the transaction holds the readings a still-missing + // image is a deliberate user removal, NOT pending hydration (else the baseline would defer forever) + it('is NOT pending when a readings-bearing draft is hydrated but its image was removed from the transaction', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {odometerStart: 100, odometerEnd: 250})).toBe(false); + }); + + // A readings + image draft on a fresh/wiped transaction is still pending via the readings (the image + // rides along in the same atomic hydration merge), so it must hydrate after a refresh + it('is pending while a readings+image draft has not hydrated into an empty transaction', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {})).toBe(true); + }); + + // An image-only draft (no readings) has no reading to mark the hydration window, so its image presence is + // what signals "still pending" - required so an image-only save-for-later restores after a page refresh + it('is pending while an image-only draft is missing from the transaction', () => { + const draft = {odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {})).toBe(true); + expect(isOdometerDraftPendingHydration(draft, {odometerEndImage: {uri: 'other.uri'}})).toBe(true); + }); + + it('is NOT pending once an image-only draft has hydrated into the transaction', () => { + const draft = {odometerStartImage: 'data:image/png;base64,xxx'}; + expect(isOdometerDraftPendingHydration(draft, {odometerStartImage: {uri: 'image.uri'}})).toBe(false); + }); + + it('is NOT pending when the transaction already has an image the draft lacks (transaction is ahead)', () => { + const draft = {odometerStartReading: 100, odometerEndReading: 250}; + const comment = {odometerStart: 100, odometerEnd: 250, odometerStartImage: {uri: 'image.uri'}}; + expect(isOdometerDraftPendingHydration(draft, comment)).toBe(false); + }); + }); + + describe('getOdometerHasUnsavedChanges', () => { + // Guard active, not mid-typing, a save-for-later readings draft that has fully hydrated into the + // transaction, image URIs matching their baseline, no reading change => nothing unsaved + const STEADY: OdometerUnsavedChangesState = { + isGuardActive: true, + isUserTyping: false, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OdometerDraft, + currentComment: {odometerStart: 100, odometerEnd: 250}, + transactionStartImageUri: '', + transactionEndImageUri: '', + baselineStartImageUri: '', + baselineEndImageUri: '', + hasReadingChanges: false, + }; + + const buildState = (overrides: Partial = {}): OdometerUnsavedChangesState => ({...STEADY, ...overrides}); + + it('returns false in a steady state (draft hydrated, nothing changed)', () => { + expect(getOdometerHasUnsavedChanges(STEADY)).toBe(false); + }); + + it('returns false when the discard guard is inactive, even if everything else looks changed', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + isGuardActive: false, + isUserTyping: true, + hasReadingChanges: true, + transactionStartImageUri: 'b.jpg', + baselineStartImageUri: 'a.jpg', + }), + ), + ).toBe(false); + }); + + // The user ADDS an image the draft lacked, so the transaction is ahead of the draft. The presence-only + // isOdometerDraftPendingHydration reports "not pending", so without the image guard the modal is suppressed + it('detects an added image when the draft carried none (the false-negative being fixed)', () => { + expect(getOdometerHasUnsavedChanges(buildState({transactionStartImageUri: 'new.jpg', baselineStartImageUri: ''}))).toBe(true); + }); + + // Mirror regression guard: a readings-bearing draft whose image is absent from the transaction (removed) + // has no transaction-vs-baseline image diff, so it must STAY silent (the previously-fixed false-positive) + it('stays silent when the image was removed (baseline and transaction both have no image)', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'} as unknown as OdometerDraft, + transactionStartImageUri: '', + baselineStartImageUri: '', + }), + ), + ).toBe(false); + }); + + it('detects a swapped image (content differs from the baseline)', () => { + expect(getOdometerHasUnsavedChanges(buildState({transactionStartImageUri: 'b.jpg', baselineStartImageUri: 'a.jpg'}))).toBe(true); + }); + + it('detects a reading change while the user is actively typing', () => { + expect(getOdometerHasUnsavedChanges(buildState({isUserTyping: true, hasReadingChanges: true}))).toBe(true); + }); + + // Proves we did NOT gate the clause on !hasReadingChanges: a readings diff that is NOT from live typing + // (e.g. baseline drift after an external resync) against a hydrated draft must stay suppressed + it('suppresses a non-typing reading diff against a hydrated draft (readings false-positive guard)', () => { + expect(getOdometerHasUnsavedChanges(buildState({isUserTyping: false, hasReadingChanges: true}))).toBe(false); + }); + + // User changes a reading + Next with a save-for-later draft, so the transaction is AHEAD of the draft. The + // directional check reports "not pending", but the change is genuinely unsaved -> leaving MUST still prompt + it('detects a reading change when the transaction is AHEAD of the draft (changed + Next + back)', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + odometerDraft: {odometerStartReading: 100, odometerEndReading: 300} as unknown as OdometerDraft, + currentComment: {odometerStart: 100, odometerEnd: 400}, + hasReadingChanges: true, + }), + ), + ).toBe(true); + }); + + it('detects when both readings and an image changed', () => { + expect( + getOdometerHasUnsavedChanges( + buildState({ + hasReadingChanges: true, + transactionStartImageUri: 'b.jpg', + baselineStartImageUri: 'a.jpg', + }), + ), + ).toBe(true); + }); + + // No save-for-later draft => the draft clause never applies, so a genuine image change must prompt + it('detects an image change when there is no draft at all', () => { + expect(getOdometerHasUnsavedChanges(buildState({odometerDraft: undefined, transactionStartImageUri: 'b.jpg', baselineStartImageUri: ''}))).toBe(true); + }); + + it('stays silent when a saved-for-later draft is re-entered with no change', () => { + expect(getOdometerHasUnsavedChanges(buildState())).toBe(false); + }); + }); + + describe('getOdometerImageIdentity (re-mint-invariant)', () => { + it('is empty for a missing image', () => { + expect(getOdometerImageIdentity(undefined)).toBe(''); + expect(getOdometerImageIdentity(null)).toBe(''); + }); + + // A non-user blob re-mint (base64 -> blob on resume/reload) keeps name + size and only changes the uri, so + // the identity must stay equal - this is what lets the discard guard stay silent on a re-mint + it('is invariant under a blob re-mint (same name + size, new uri)', () => { + const original = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const reminted = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + expect(getOdometerImageIdentity(reminted)).toBe(getOdometerImageIdentity(original)); + }); + + // A genuine user swap is a different file (different name and/or size), so the identity must change - this is + // what lets the discard guard fire on an add/swap/remove + it('changes when the user swaps to a different file (different name and size)', () => { + const a = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const b = {uri: 'blob:xyz', name: 'b.jpg', type: 'image/jpeg', size: 5678}; + expect(getOdometerImageIdentity(b)).not.toBe(getOdometerImageIdentity(a)); + }); + + it('uses the uri string directly for native images', () => { + expect(getOdometerImageIdentity('file:///path/to/a.jpg')).toBe('file:///path/to/a.jpg'); + }); + }); }); From fd2a38e96a9d213052c667cdf517358cef183f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 12 Jun 2026 12:40:14 +0200 Subject: [PATCH 15/42] fix(odometer): guard resync from re-hydrating user-cleared readings on tab/back --- config/eslint/eslint.seatbelt.tsv | 4 +- .../hooks/useOdometerReadingsState.ts | 34 +- .../IOURequestStepDistance/odometerResync.ts | 16 +- ...equestStepDistanceOdometerNextSyncTest.tsx | 321 ++++++++++++++++++ ...questStepDistanceOdometerSflResumeTest.tsx | 230 +++++++++++++ .../hooks/useOdometerReadingsState.test.ts | 89 ++++- tests/unit/odometerResync.test.ts | 14 + 7 files changed, 689 insertions(+), 19 deletions(-) create mode 100644 tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx create mode 100644 tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index da013c476183..f8dbf769a4eb 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1660,6 +1660,8 @@ "../../tests/ui/IOURequestStartPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/ui/IOURequestStepAmountDraftTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 +"../../tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/ui/IOURequestStepDistanceTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/ui/IOURequestStepScanTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/ui/InitialSettingsPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1951,7 +1953,7 @@ "../../tests/unit/hooks/useIsSidebarRouteActive.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/hooks/useLazyAsset.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/hooks/useNonPersonalCardList.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../tests/unit/hooks/useOdometerReadingsState.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/unit/hooks/useOdometerReadingsState.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/unit/hooks/useOdometerReceiptStitcherTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/hooks/useOdometerTransactionBackup.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 16 diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index ce623bbddacc..e76ed70c5e26 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -9,19 +9,19 @@ type UseOdometerReadingsStateParams = { /** The transaction whose odometer values seed and re-sync the local form state. */ currentTransaction: OnyxEntry; - /** True when editing an existing odometer expense — re-sync runs whenever transaction values change. */ + /** True when editing an existing odometer expense - re-sync runs whenever transaction values change. */ isEditing: boolean; /** The currently selected distance-request-type tab; used to detect "switched away from odometer". */ selectedTab: string | undefined; - /** True while the selected-tab Onyx key is still loading — suppresses the tab-reset effect. */ + /** True while the selected-tab Onyx key is still loading - suppresses the tab-reset effect. */ isLoadingSelectedTab: boolean; /** True once `useRestartOnOdometerImagesFailure` has finished verifying any blob URIs in the transaction (gates the initial-refs snapshot). */ hasVerifiedBlobs: boolean; - /** Save-for-later draft, if any — used to defer the initial-refs snapshot until the draft has hydrated into the transaction. */ + /** Save-for-later draft, if any - used to defer the initial-refs snapshot until the draft has hydrated into the transaction. */ odometerDraft: OnyxEntry; }; @@ -53,22 +53,22 @@ type UseOdometerReadingsStateResult = { /** Tracks the latest `endReading`. */ endReadingRef: React.RefObject; - /** The start-reading value captured at mount — used by discard-changes confirmation. */ + /** The start-reading value captured at mount - used by discard-changes confirmation. */ initialStartReadingRef: React.RefObject; - /** The end-reading value captured at mount — used by discard-changes confirmation. */ + /** The end-reading value captured at mount - used by discard-changes confirmation. */ initialEndReadingRef: React.RefObject; - /** The start-odometer image captured at mount — used by discard-changes confirmation. */ + /** The start-odometer image captured at mount - used by discard-changes confirmation. */ initialStartImageRef: React.RefObject; - /** The end-odometer image captured at mount — used by discard-changes confirmation. */ + /** The end-odometer image captured at mount - used by discard-changes confirmation. */ initialEndImageRef: React.RefObject; /** Resets local form state and the initial refs back to their defaults. */ resetOdometerLocalState: () => void; - /** True once the initial baseline has been captured — gates discard-changes detection. */ + /** True once the initial baseline has been captured - gates discard-changes detection. */ hasInitializedRefs: React.RefObject; }; @@ -137,7 +137,7 @@ function useOdometerReadingsState({ if (!isEditing && !isOdometerTransaction) { return; } - // Wait for blob verification — otherwise Cmd+R would snapshot a stale blob URI before + // Wait for blob verification - otherwise Cmd+R would snapshot a stale blob URI before // useRestartOnOdometerImagesFailure swaps in a fresh one, and the diff would look like an edit. if (!hasVerifiedBlobs) { return; @@ -151,12 +151,21 @@ function useOdometerReadingsState({ const currentEnd = currentTransaction?.comment?.odometerEnd; const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; + // Snapshot the transaction as the baseline; the discard guard then diffs current-vs-baseline. No "user + // edited" tracking is needed: + // - Create: this runs at the (empty) mount before the user types, and the screen stays mounted across + // Next -> back, so the empty baseline survives and a committed-but-unsaved reading reads as a change + // - SFL-resume / reload: the fresh mount absorbs the hydrated value (= the saved state), so a clean resume + // stays silent + // - Images use a re-mint-invariant identity in the diff (getOdometerImageIdentity), so a non-user blob + // re-mint is absorbed while a genuine add/swap/remove is caught - no need to special-case the baseline initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; initialEndImageRef.current = currentTransaction?.comment?.odometerEndImage; hasInitializedRefs.current = true; }, [ + currentTransaction?.transactionID, currentTransaction?.iouRequestType, currentTransaction?.comment, currentTransaction?.comment?.odometerStart, @@ -168,8 +177,11 @@ function useOdometerReadingsState({ odometerDraft, ]); - // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) - // This updates the current state, but NOT the initial refs (those are set only once on mount) + // Initialize current state (NOT the initial refs, which are set once on mount) from the transaction when editing + // or when it has data - but not on tab switch. Mirrors the first three branches of + // `shouldInitializeOdometerFromTransaction` (odometerResync.ts is the source of truth), minus its `!isUserTyping` + // guard: the typing ref lives in the component, and this effect's deps are only readings + isEditing (no image + // deps), so it can't hit the clear-then-delete-image bug the guard fixes. Keep in sync. useEffect(() => { const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts index d50b50e07af9..293e0ae725e4 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts @@ -4,8 +4,8 @@ * * That effect keeps the local readings in sync with the transaction without clobbering in-progress typing, and * slides the discard-changes baseline when the transaction is changed elsewhere (e.g. an edit saved from the - * confirmation step). The branching is subtle and easy to regress, so it lives here as small pure predicates - * that can be unit-tested in isolation from the effect's ref mutations + * confirmation step). The branching lives here as small pure predicates that can be unit-tested in isolation + * from the effect's ref mutations */ type OdometerResyncState = { @@ -60,14 +60,18 @@ function isExternalOdometerResync(state: OdometerResyncState): boolean { * Whether the local readings should be (re)initialized from the transaction: * 1. first mount with transaction data, or * 2. editing with transaction data and no local state yet, or - * 3. transaction has data but local state is empty (user navigated back from another page), or - * 4. an external resync arrived (the typing guard inside it avoids clobbering in-progress keystrokes) + * 3. transaction has data but local state is empty (navigated back from another page), or + * 4. an external resync arrived + * + * Branches 2-4 carry a `!isUserTyping` guard (inside `isExternalOdometerResync` for branch 4) so they don't + * re-hydrate readings the user intentionally cleared - which leaves local state empty without writing the + * transaction, looking identical to "navigated back". Branch 1 is unguarded: a fresh mount must hydrate the baseline. */ function shouldInitializeOdometerFromTransaction(state: OdometerResyncState, isExternalResync: boolean): boolean { return ( (!state.hasInitialized && state.hasTransactionData) || - (state.isEditing && state.hasTransactionData && !state.hasLocalState) || - (state.hasTransactionData && !state.hasLocalState && state.hasInitialized) || + (state.isEditing && state.hasTransactionData && !state.hasLocalState && !state.isUserTyping) || + (state.hasTransactionData && !state.hasLocalState && state.hasInitialized && !state.isUserTyping) || isExternalResync ); } diff --git a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx new file mode 100644 index 000000000000..e1354a04807f --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx @@ -0,0 +1,321 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; +import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {OdometerDraft, Report, Transaction} from '@src/types/onyx'; +import createRandomTransaction from '../utils/collections/transaction'; +import getOnyxValue from '../utils/getOnyxValue'; +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => ({ + default: jest.fn(), + MarkerView: jest.fn(), + setAccessToken: jest.fn(), +})); + +jest.mock('@components/LocaleContextProvider', () => { + const React2 = require('react'); + const defaultContextValue = { + translate: (path: string) => path, + numberFormat: (number: number) => String(number), + getLocalDateFromDatetime: () => new Date(), + datetimeToRelative: () => '', + datetimeToCalendarTime: () => '', + formatPhoneNumber: (phone: string) => phone, + toLocaleDigit: (digit: string) => digit, + toLocaleOrdinal: (number: number) => String(number), + fromLocaleDigit: (localeDigit: string) => localeDigit, + localeCompare: (a: string, b: string) => a.localeCompare(b), + formatTravelDate: () => '', + preferredLocale: 'en', + }; + const LocaleContext = React2.createContext(defaultContextValue); + return { + LocaleContext, + LocaleContextProvider: ({children}: {children: React.ReactNode}) => React2.createElement(LocaleContext.Provider, {value: defaultContextValue}, children), + }; +}); + +// Neutralize the post-Next navigation so the test can inspect Onyx right after Next, in isolation +jest.mock('@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@libs/actions/MapboxToken', () => ({ + init: jest.fn(), + stop: jest.fn(), +})); + +jest.mock('@components/ProductTrainingContext', () => ({ + useProductTrainingContext: () => [false], +})); + +jest.mock('@hooks/useShowNotFoundPageInIOUStep', () => () => false); +jest.mock('@src/hooks/useResponsiveLayout'); + +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ + __esModule: true, + default: () => ({didScreenTransitionEnd: true}), +})); + +jest.mock('@libs/Navigation/navigationRef', () => ({ + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), + getState: jest.fn(() => ({})), +})); + +jest.mock('@libs/Navigation/Navigation', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), + dismissModalWithReport: jest.fn(), + navigationRef: mockRef, + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(() => undefined), + removeScreenByKey: jest.fn(), + }; +}); + +jest.mock('@react-navigation/native', () => { + const mockRef = { + getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), + getState: jest.fn(() => ({})), + }; + return { + createNavigationContainerRef: jest.fn(() => mockRef), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + useRoute: jest.fn(), + }; +}); + +const ACCOUNT_ID = 1; +const ACCOUNT_LOGIN = 'test@user.com'; +const REPORT_ID = 'report-odometer-1'; +const TRANSACTION_ID = 'txn-odometer-1'; + +function createTestReport(): Report { + return { + reportID: REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isPinned: false, + lastVisibleActionCreated: '', + lastReadTime: '', + participants: { + [ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}, + }, + }; +} + +function createOdometerDraftTransaction(): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + // Start with no odometer readings - the user enters them in the form + odometerStart: undefined, + odometerEnd: undefined, + customUnit: { + customUnitID: 'test-unit-id', + customUnitRateID: 'test-rate-id', + name: 'Distance', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + } as unknown as Transaction; +} + +function renderCreateOdometer() { + return render( + + + + + , + ); +} + +// Returns the underlying TextInput (not the floating-label ) for a given odometer field label +const odometerInput = (labelKey: string) => screen.getAllByLabelText(labelKey).find((element) => 'value' in element.props)!; + +const enterReadingsAndPressNext = async (start: string, end: string) => { + fireEvent.changeText(odometerInput('distance.odometer.startReading'), start); + fireEvent.changeText(odometerInput('distance.odometer.endReading'), end); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('next-save-button')); + // Flush twice so any Onyx writes triggered by Next would land before we assert + await waitForBatchedUpdatesWithAct(); + await waitForBatchedUpdatesWithAct(); +}; + +describe('IOURequestStepDistanceOdometer - create-flow Next does not write the save-for-later draft', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerDraftTransaction()); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + }); + + // "Next" is not a save action (only "Save for later" and the confirmation "Save" write the draft), so it must + // leave an existing draft untouched. The directional isOdometerDraftPendingHydration reports the staler draft + // as NOT pending, which is what prevents a false discard modal on re-entry + it('leaves an existing save-for-later draft untouched on Next, and is reported as not pending', async () => { + const existingDraft: OdometerDraft = {odometerStartReading: 50, odometerEndReading: 60}; + await act(async () => { + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, existingDraft); + }); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + await enterReadingsAndPressNext('100', '300'); + + const draftAfter = await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT); + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + + // Next did NOT touch ODOMETER_DRAFT - it still holds the originally saved readings + expect(draftAfter?.odometerStartReading).toBe(50); + expect(draftAfter?.odometerEndReading).toBe(60); + // The transaction still received the freshly entered readings + expect(draftTransaction?.comment?.odometerStart).toBe(100); + expect(draftTransaction?.comment?.odometerEnd).toBe(300); + // Even though the draft is now staler than the transaction, the directional predicate reports it as + // NOT pending hydration (the transaction already holds readings) -> no false discard modal on re-entry + expect(isOdometerDraftPendingHydration(draftAfter, draftTransaction?.comment)).toBe(false); + }); + + // The draft still holds an image the user has since removed from the transaction. Next leaves the draft + // untouched, and the directional check reports the staler-image draft as NOT pending -> no false discard modal + it('reports a readings+image draft as not pending after its image is removed from the transaction', async () => { + const existingDraft: OdometerDraft = {odometerStartReading: 50, odometerEndReading: 60, odometerStartImage: 'data:image/png;base64,xxx'}; + await act(async () => { + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, existingDraft); + }); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + await enterReadingsAndPressNext('100', '300'); + + const draftAfter = await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT); + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + + // Next did NOT touch ODOMETER_DRAFT - it still holds the image. + expect(draftAfter?.odometerStartImage).toBe('data:image/png;base64,xxx'); + // The transaction holds the freshly entered readings but no image (the user removed it before Next) + expect(draftTransaction?.comment?.odometerStart).toBe(100); + expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); + // The staler-image draft is reported as NOT pending hydration -> no false discard modal on re-entry + expect(isOdometerDraftPendingHydration(draftAfter, draftTransaction?.comment)).toBe(false); + }); + + // Cleared readings must NOT reappear when an image is later deleted. The user clears both reading inputs (local + // state only), then deletes an image, which writes the transaction and re-fires the resync effect while + // isUserTyping=true. The `!isUserTyping` guard on the predicate's reading branches must keep them cleared + it('keeps readings cleared when an odometer image is deleted after clearing them', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + comment: {odometerStart: 100, odometerEnd: 300, odometerStartImage: 'data:image/png;base64,xxx'}, + }); + }); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + const startInput = odometerInput('distance.odometer.startReading'); + const endInput = odometerInput('distance.odometer.endReading'); + + // The form hydrates from the transaction on mount + expect(startInput.props.value).toBe('100'); + expect(endInput.props.value).toBe('300'); + + // User clears BOTH inputs (updates local state + the typing flag; the transaction is NOT written) + fireEvent.changeText(startInput, ''); + fireEvent.changeText(endInput, ''); + await waitForBatchedUpdatesWithAct(); + expect(startInput.props.value).toBe(''); + expect(endInput.props.value).toBe(''); + + // User deletes the start image - mirrors removeMoneyRequestOdometerImage + goBack to the mounted step + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {comment: {odometerStartImage: null}}); + }); + await waitForBatchedUpdatesWithAct(); + + // The cleared readings must stay cleared - they must NOT be re-hydrated from the stale transaction + expect(startInput.props.value).toBe(''); + expect(endInput.props.value).toBe(''); + }); + + // Pressing Next never writes the draft in the plain create flow either, so an unrelated future expense + // is never left with an orphaned ODOMETER_DRAFT + it('does not create a draft on Next when no save-for-later draft exists', async () => { + expect(await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)).toBeUndefined(); + + renderCreateOdometer(); + await waitForBatchedUpdatesWithAct(); + + await enterReadingsAndPressNext('100', '300'); + + // Next does not write ODOMETER_DRAFT + expect(await getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)).toBeUndefined(); + // The transaction itself still received the readings + const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); + expect(draftTransaction?.comment?.odometerStart).toBe(100); + expect(draftTransaction?.comment?.odometerEnd).toBe(300); + }); +}); diff --git a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx new file mode 100644 index 000000000000..18e538c83e22 --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx @@ -0,0 +1,230 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import * as OdometerTransactionUtils from '@libs/actions/OdometerTransactionUtils'; +import DistanceTabGuardContext from '@pages/iou/request/DistanceTabGuardContext'; +import type {DistanceTabGuard, RegisterDistanceTabGuard} from '@pages/iou/request/DistanceTabGuardContext'; +import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import type {OdometerDraft, Report, Transaction} from '@src/types/onyx'; +import type {FileObject} from '@src/types/utils/Attachment'; +import createRandomTransaction from '../utils/collections/transaction'; +import {signInWithTestUser} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@rnmapbox/maps', () => ({default: jest.fn(), MarkerView: jest.fn(), setAccessToken: jest.fn()})); + +// Keep the real module (getOdometerHasUnsavedChanges + the actions) but stub saveOdometerDraft so "Save for later" +// resolves deterministically in jsdom (the real one tries to read a blob: URI) +jest.mock('@libs/actions/OdometerTransactionUtils', () => { + const actual = jest.requireActual('@libs/actions/OdometerTransactionUtils'); + return { + ...actual, + saveOdometerDraft: jest.fn(() => Promise.resolve()), + }; +}); + +jest.mock('@components/LocaleContextProvider', () => { + const React2 = require('react'); + const defaultContextValue = { + translate: (path: string) => path, + numberFormat: (n: number) => String(n), + getLocalDateFromDatetime: () => new Date(), + datetimeToRelative: () => '', + datetimeToCalendarTime: () => '', + formatPhoneNumber: (p: string) => p, + toLocaleDigit: (d: string) => d, + toLocaleOrdinal: (n: number) => String(n), + fromLocaleDigit: (d: string) => d, + localeCompare: (a: string, b: string) => a.localeCompare(b), + formatTravelDate: () => '', + preferredLocale: 'en', + }; + const LocaleContext = React2.createContext(defaultContextValue); + return {LocaleContext, LocaleContextProvider: ({children}: {children: React.ReactNode}) => React2.createElement(LocaleContext.Provider, {value: defaultContextValue}, children)}; +}); +jest.mock('@pages/iou/request/step/IOURequestStepDistance/handleMoneyRequestStepDistanceNavigation', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@libs/actions/MapboxToken', () => ({init: jest.fn(), stop: jest.fn()})); +jest.mock('@components/ProductTrainingContext', () => ({useProductTrainingContext: () => [false]})); +jest.mock('@hooks/useShowNotFoundPageInIOUStep', () => () => false); +jest.mock('@src/hooks/useResponsiveLayout'); +jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({__esModule: true, default: () => ({didScreenTransitionEnd: true})})); +jest.mock('@libs/Navigation/navigationRef', () => ({getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), getState: jest.fn(() => ({}))})); +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), + dismissModalWithReport: jest.fn(), + navigationRef: {getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), getState: jest.fn(() => ({}))}, + setNavigationActionToMicrotaskQueue: jest.fn((cb: () => void) => cb()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), + isNavigationReady: jest.fn(() => Promise.resolve()), + getReportRouteByID: jest.fn(() => undefined), + removeScreenByKey: jest.fn(), +})); +jest.mock('@react-navigation/native', () => ({ + createNavigationContainerRef: jest.fn(() => ({getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Distance_Create', params: {}})), getState: jest.fn(() => ({}))})), + useIsFocused: () => true, + useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), + useFocusEffect: jest.fn(), + usePreventRemove: jest.fn(), + useRoute: jest.fn(), +})); + +const ACCOUNT_ID = 1; +const REPORT_ID = 'report-sfl-resume'; +const TRANSACTION_ID = 'txn-sfl-resume'; +const START_IMAGE: FileObject = {uri: 'data:image/png;base64,sfl', name: 'a.png', type: 'image/png', size: 1234}; + +function createReport(): Report { + return { + reportID: REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + ownerAccountID: ACCOUNT_ID, + stateNum: CONST.REPORT.STATE_NUM.OPEN, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + isPinned: false, + lastVisibleActionCreated: '', + lastReadTime: '', + participants: {[ACCOUNT_ID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, role: CONST.REPORT.ROLE.MEMBER}}, + }; +} + +function createOdometerTransaction(withImage: boolean): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + odometerStart: withImage ? 100 : undefined, + odometerEnd: withImage ? 300 : undefined, + odometerStartImage: withImage ? START_IMAGE : undefined, + customUnit: {customUnitID: 'u', customUnitRateID: 'r', name: 'Distance', distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, + }, + } as unknown as Transaction; +} + +function renderCreateFlow(register: RegisterDistanceTabGuard) { + return render( + + + + + + + , + ); +} + +const odometerInput = (labelKey: string) => screen.getAllByLabelText(labelKey).find((element) => 'value' in element.props)!; + +describe('IOURequestStepDistanceOdometer - create-flow discard guard (no stored user-edit marks)', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, 'test@user.com'); + }); + + // After capture + "Save for later" + same-session resume, the draft hydrates a re-minted image into + // the transaction. The baseline absorbs it and the re-mint-invariant identity (name|size) reports no change + it('Save for later then resume reports no unsaved changes', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerTransaction(false)); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + // Session 1: type readings + capture a start image (the real marking path), then Save for later + const {unmount} = renderCreateFlow(() => () => {}); + await waitForBatchedUpdatesWithAct(); + fireEvent.changeText(odometerInput('distance.odometer.startReading'), '100'); + fireEvent.changeText(odometerInput('distance.odometer.endReading'), '300'); + await act(async () => { + OdometerTransactionUtils.setMoneyRequestOdometerImage(createOdometerTransaction(false), CONST.IOU.ODOMETER_IMAGE_TYPE.START, START_IMAGE, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('save-for-later-button')); + await waitForBatchedUpdatesWithAct(); + await waitForBatchedUpdatesWithAct(); + + unmount(); + + // Session 2 (same JS session, no reload): resume with the draft + image hydrated into the transaction + const existingDraft: OdometerDraft = {odometerStartReading: 100, odometerEndReading: 300, odometerStartImage: 'data:image/png;base64,sfl'}; + await act(async () => { + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, existingDraft); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerTransaction(true)); + }); + let resumeGuard: DistanceTabGuard | undefined; + renderCreateFlow((guard) => { + resumeGuard = guard; + return () => {}; + }); + await waitForBatchedUpdatesWithAct(); + + expect(resumeGuard?.getHasUnsavedChanges() ?? false).toBe(false); + }); + + // In the create flow, Next then leaving must still prompt. The baseline is snapshotted EMPTY at the + // (empty) mount and survives Next -> back (screen stays mounted), so the committed readings differ from it + it('reports unsaved changes after Next in the create flow', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerTransaction(false)); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + let guard: DistanceTabGuard | undefined; + renderCreateFlow((capturedGuard) => { + guard = capturedGuard; + return () => {}; + }); + await waitForBatchedUpdatesWithAct(); + + // Empty mount baseline -> nothing unsaved yet + expect(guard?.getHasUnsavedChanges() ?? false).toBe(false); + + // Type readings and press Next (writes the throwaway draft and lowers the typing flag, without remounting) + fireEvent.changeText(odometerInput('distance.odometer.startReading'), '100'); + fireEvent.changeText(odometerInput('distance.odometer.endReading'), '300'); + fireEvent.press(screen.getByTestId('next-save-button')); + await waitForBatchedUpdatesWithAct(); + + // The committed-but-unsaved readings differ from the surviving empty baseline -> leaving must prompt + expect(guard?.getHasUnsavedChanges() ?? false).toBe(true); + }); +}); diff --git a/tests/unit/hooks/useOdometerReadingsState.test.ts b/tests/unit/hooks/useOdometerReadingsState.test.ts index 077d9ad09f20..c931f38da98f 100644 --- a/tests/unit/hooks/useOdometerReadingsState.test.ts +++ b/tests/unit/hooks/useOdometerReadingsState.test.ts @@ -39,7 +39,7 @@ describe('useOdometerReadingsState', () => { it('starts with empty form state, then hydrates startReading/endReading from the transaction', () => { const {result} = renderHook(() => useOdometerReadingsState(baseParams)); - // The sync-from-transaction effect runs synchronously after mount. + // The sync-from-transaction effect runs synchronously after mount expect(result.current.startReading).toBe('100'); expect(result.current.endReading).toBe('250'); expect(result.current.formError).toBe(''); @@ -74,6 +74,76 @@ describe('useOdometerReadingsState', () => { expect(result.current.hasInitializedRefs.current).toBe(false); }); + // After Next the transaction holds new readings but the draft is staler. The directional check reports NOT + // pending, so the baseline snapshots from the transaction - re-entering no longer looks like an unsaved change + it('captures the baseline from the transaction when the (staler) draft is not pending hydration', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction({odometerStart: 120, odometerEnd: 300}), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OnyxTypes.OdometerDraft, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartReadingRef.current).toBe('120'); + expect(result.current.initialEndReadingRef.current).toBe('300'); + }); + + // Transaction has readings but no image (user deleted it) while the draft still holds the image. The directional + // check reports NOT pending, so the baseline snapshots the transaction's true no-image state, not an empty one + it('captures a no-image baseline when a readings draft is hydrated but its image was removed', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction(), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'} as unknown as OnyxTypes.OdometerDraft, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartImageRef.current).toBeUndefined(); + expect(result.current.initialEndImageRef.current).toBeUndefined(); + }); + + // The ADD-image flow relies on an EMPTY image baseline: neither draft nor transaction has an image at mount, so + // a later add differs from this empty baseline (hasImageChanges => true) and lets the discard modal fire + it('captures an empty image baseline when neither the draft nor the transaction has an image', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction(), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OnyxTypes.OdometerDraft, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartImageRef.current).toBeUndefined(); + expect(result.current.initialEndImageRef.current).toBeUndefined(); + }); + + // The SWAP-image flow relies on the baseline capturing the transaction's CURRENT image, so a later swap is + // detected as a change. When the transaction already holds an image, the baseline must snapshot it, not undefined + it('captures the transaction image into the baseline when the transaction already has one', () => { + mockIsOdometerDraftPendingHydration.mockReturnValue(false); + const startImage = {uri: 'start.jpg'}; + const endImage = {uri: 'end.jpg'}; + const {result} = renderHook(() => + useOdometerReadingsState({ + ...baseParams, + currentTransaction: buildOdometerTransaction({odometerStartImage: startImage, odometerEndImage: endImage}), + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OnyxTypes.OdometerDraft, + }), + ); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartImageRef.current).toEqual(startImage); + expect(result.current.initialEndImageRef.current).toEqual(endImage); + }); + it('skips initialization on a non-odometer transaction unless we are editing', () => { const transaction = { transactionID: 't1', @@ -127,4 +197,21 @@ describe('useOdometerReadingsState', () => { expect(result.current.inputKey).toBe(initialKey); }); + + // Create flow at the unit level: a fresh mount snapshots an EMPTY baseline, so readings typed + committed + // via Next later differ from it and leaving prompts. The screen stays mounted across Next -> back, so it survives + it('captures an empty baseline on a fresh create mount with no readings yet', () => { + const emptyTransaction = { + transactionID: 't1', + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: {}, + } as unknown as OnyxTypes.Transaction; + const {result} = renderHook(() => useOdometerReadingsState({...baseParams, currentTransaction: emptyTransaction})); + + expect(result.current.hasInitializedRefs.current).toBe(true); + expect(result.current.initialStartReadingRef.current).toBe(''); + expect(result.current.initialEndReadingRef.current).toBe(''); + expect(result.current.initialStartImageRef.current).toBeUndefined(); + expect(result.current.initialEndImageRef.current).toBeUndefined(); + }); }); diff --git a/tests/unit/odometerResync.test.ts b/tests/unit/odometerResync.test.ts index 9d784df124d5..285cd94ecff0 100644 --- a/tests/unit/odometerResync.test.ts +++ b/tests/unit/odometerResync.test.ts @@ -57,10 +57,24 @@ describe('shouldInitializeOdometerFromTransaction', () => { expect(shouldInitializeOdometerFromTransaction(buildState({isEditing: true, hasLocalState: false}), false)).toBe(true); }); + it('does not re-initialize when editing with empty local state while the user is typing (intentional clear)', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({isEditing: true, hasLocalState: false, isUserTyping: true}), false)).toBe(false); + }); + it('initializes when transaction has data but local state is empty (navigated back)', () => { expect(shouldInitializeOdometerFromTransaction(buildState({hasLocalState: false}), false)).toBe(true); }); + it('still reloads when local state is empty and the user is not typing (navigated back, fresh ref)', () => { + expect(shouldInitializeOdometerFromTransaction(buildState({hasLocalState: false, isUserTyping: false}), false)).toBe(true); + }); + + it('does not re-hydrate cleared readings while the user is typing (clear-then-delete-image regression)', () => { + // User cleared both inputs (local state empty, typing flag set) then deleted an image; the + // transaction still holds the old readings. The third branch must NOT reload them. + expect(shouldInitializeOdometerFromTransaction(buildState({hasLocalState: false, isUserTyping: true}), false)).toBe(false); + }); + it('initializes when an external resync arrived', () => { expect(shouldInitializeOdometerFromTransaction(buildState({hasTransactionData: false}), true)).toBe(true); }); From b174200103187c4d4d75a8bdc435342987729878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 12 Jun 2026 15:53:30 +0200 Subject: [PATCH 16/42] revert(odometer): drop web browser-back discard handling, keep tab-switch Removes the browser-Back-button discard mechanism introduced in 4ab9a7357f9: the tab-history replacement router, goForwardInBrowserHistory helper, and the page-level useActiveTabGuardDiscardConfirmation hook. Restores the per-step useDiscardChangesConfirmation (header/hardware back) and keeps the core tab-switch discard modal and the resync guard untouched. --- src/CONST/index.ts | 4 - .../useDiscardChangesConfirmation/index.ts | 4 +- src/libs/Navigation/OnyxTabNavigator.tsx | 7 -- .../OnyxTabNavigatorConfig/index.ts | 12 +- .../OnyxTabNavigatorConfig/index.web.ts | 12 +- .../getTabHistoryReplacementRouter.ts | 81 ------------ .../goForwardInBrowserHistory/index.ts | 7 -- .../goForwardInBrowserHistory/index.web.ts | 14 --- .../iou/request/DistanceRequestStartPage.tsx | 48 ------- .../iou/request/DistanceTabGuardContext.tsx | 8 +- .../step/IOURequestStepDistanceOdometer.tsx | 50 ++------ .../getTabHistoryReplacementRouter.test.ts | 118 ------------------ 12 files changed, 26 insertions(+), 339 deletions(-) delete mode 100644 src/libs/Navigation/getTabHistoryReplacementRouter.ts delete mode 100644 src/libs/Navigation/goForwardInBrowserHistory/index.ts delete mode 100644 src/libs/Navigation/goForwardInBrowserHistory/index.web.ts delete mode 100644 tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 38ecbaa7b21e..2fc9c84d5205 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5984,10 +5984,6 @@ const CONST = { GO_BACK: 'GO_BACK', RESET: 'RESET', - /** React Navigation TabRouter action types that switch tabs; not exported as constants by the library */ - JUMP_TO: 'JUMP_TO', - NAVIGATE_DEPRECATED: 'NAVIGATE_DEPRECATED', - /** These action types are custom for RootNavigator */ DISMISS_MODAL: 'DISMISS_MODAL', REPLACE_FULLSCREEN_UNDER_RHP: 'REPLACE_FULLSCREEN_UNDER_RHP', diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index f1d9f809c92a..7d3b5d5112f5 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -6,7 +6,6 @@ import useBeforeRemove from '@hooks/useBeforeRemove'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import Log from '@libs/Log'; -import goForwardInBrowserHistory from '@libs/Navigation/goForwardInBrowserHistory'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -125,10 +124,11 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi return; } shouldNavigateBack.current = true; - goForwardInBrowserHistory(); if (closing) { + window.history.go(1); return; } + window.history.go(1); navigateAfterInteraction(showDiscardModal); }); diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 0c8535803d15..b87a155c1653 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -15,7 +15,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import getTabHistoryReplacementRouter from './getTabHistoryReplacementRouter'; import {defaultScreenOptions} from './OnyxTabNavigatorConfig'; type OnyxTabNavigatorProps = ChildrenProps & { @@ -108,11 +107,6 @@ function OnyxTabNavigator({ const tabNames = useMemo(() => getTabNames(children), [children]); - // On web, opts the allowlisted navigators into replacing (instead of pushing) browser history on tab - // switches, so the browser BACK button leaves the flow and fires the existing discard guards rather - // than silently popping a nested-tab URL. `undefined` (native / non-allowlisted) keeps the stock router. - const tabHistoryRouter = useMemo(() => getTabHistoryReplacementRouter(id), [id]); - const validInitialTab = selectedTab && tabNames.includes(selectedTab) ? selectedTab : defaultSelectedTab; const LazyPlaceholder = useCallback(() => { @@ -171,7 +165,6 @@ function OnyxTabNavigator({ (original: Router, Action>) => Partial, Action>>; - -/** - * Navigators whose tab switches should NOT add a browser-history entry - * - * Kept as a single allowlist so the behavior can be widened later by adding an id here. Scoped today to - * the distance-create flow, which is the only tab navigator that loses unsaved local state (the odometer - * readings) when the browser BACK button silently pops a nested-tab URL - */ -const NAVIGATORS_WITH_REPLACED_TAB_HISTORY = new Set([CONST.TAB.DISTANCE_REQUEST_TYPE]); - -// Action types that switch tabs. `NAVIGATE` comes from URL/linking-driven switches; `JUMP_TO` / -// `NAVIGATE_DEPRECATED` are React Navigation's internal TabRouter action types. -const TAB_SWITCH_ACTION_TYPES = new Set([CONST.NAVIGATION.ACTION_TYPE.NAVIGATE, CONST.NAVIGATION.ACTION_TYPE.JUMP_TO, CONST.NAVIGATION.ACTION_TYPE.NAVIGATE_DEPRECATED]); - -/** - * Builds the `UNSTABLE_router` override that collapses the tab navigator's `history` down to just the - * focused route on every tab switch - * - * Why: the distance-create tabs are routed URL sub-paths. By default each tab switch grows the tab - * navigator's `state.history`, which makes React Navigation's `useLinking` perform a browser - * `history.push` (a new history entry). The browser BACK button then pops *within* the nested tab - * navigator - switching tabs silently and wiping the odometer's unsaved local readings, bypassing the - * `beforeRemove`/`transitionStart`/`tabPress` discard guards - * - * Keeping the history length constant (always 1) makes `useLinking`'s history delta 0, so it calls - * `history.replace` instead of `history.push`: the URL still updates (refresh/deep-link keep working) - * but no browser-history entry is added. Browser BACK then pops the whole DISTANCE_CREATE screen and - * fires the existing `beforeRemove` discard guard - the same path as the header/hardware back button. - * As a bonus, an in-navigator `GO_BACK` on a length-1 history returns `null`, so back bubbles to the - * parent stack and closes the flow - * - * Exported separately from {@link getTabHistoryReplacementRouter} so the clamping behavior can be unit - * tested independently of the platform/allowlist gating - */ -function createTabHistoryReplacementRouter(): TabRouterOverrideFactory { - return (original) => ({ - getStateForAction(state, action, options) { - const nextState = original.getStateForAction(state, action, options); - - // `null` => the action isn't handled by this navigator and should bubble up to the parent - if (!nextState || !TAB_SWITCH_ACTION_TYPES.has(action.type)) { - return nextState; - } - - // Tab-switch actions always resolve to a full TabNavigationState (with `history`/`index`) - const tabState = nextState as TabNavigationState; - const focusedRoute = tabState.routes?.at(tabState.index); - if (!tabState.history || !focusedRoute) { - return nextState; - } - - return {...tabState, history: [{type: 'route', key: focusedRoute.key}]}; - }, - }); -} - -/** - * Returns the tab-history `UNSTABLE_router` override for the given navigator id, or `undefined` when the - * default (history-growing) behavior should be kept. Returns `undefined` on native (no browser history) - * and for any navigator not in {@link NAVIGATORS_WITH_REPLACED_TAB_HISTORY} - */ -function getTabHistoryReplacementRouter(id: string): TabRouterOverrideFactory | undefined { - if (!shouldReplaceTabHistoryOnTabSwitch || !NAVIGATORS_WITH_REPLACED_TAB_HISTORY.has(id)) { - return undefined; - } - - return createTabHistoryReplacementRouter(); -} - -export default getTabHistoryReplacementRouter; -export {createTabHistoryReplacementRouter, NAVIGATORS_WITH_REPLACED_TAB_HISTORY}; -export type {TabRouterOverrideFactory}; diff --git a/src/libs/Navigation/goForwardInBrowserHistory/index.ts b/src/libs/Navigation/goForwardInBrowserHistory/index.ts deleted file mode 100644 index 1f000bbbaaa7..000000000000 --- a/src/libs/Navigation/goForwardInBrowserHistory/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Native has no browser history, so there is nothing to restore. No-op counterpart of the web - * implementation (see `index.web.ts`) - */ -function goForwardInBrowserHistory() {} - -export default goForwardInBrowserHistory; diff --git a/src/libs/Navigation/goForwardInBrowserHistory/index.web.ts b/src/libs/Navigation/goForwardInBrowserHistory/index.web.ts deleted file mode 100644 index b2a244b401d5..000000000000 --- a/src/libs/Navigation/goForwardInBrowserHistory/index.web.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Moves the browser one entry forward, undoing a browser BACK press that we want to veto - * - * The browser BACK button moves the history pointer (and fires `popstate`) BEFORE JS can intercept it, - * so react-navigation's `beforeRemove` `preventDefault()` keeps the screen mounted but cannot undo the - * URL change. Without restoring the pointer the URL stays desynced and react-navigation keeps - * re-dispatching the (blocked) RESET - re-opening the discard modal on every cancel. Going forward - * resyncs the URL with the retained navigation state - */ -function goForwardInBrowserHistory() { - window.history.go(1); -} - -export default goForwardInBrowserHistory; diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index a473d6820675..379c2779578f 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -8,9 +8,7 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; import type {TabSelectorProps} from '@components/TabSelector/types'; -import useBeforeRemove from '@hooks/useBeforeRemove'; import useConfirmModal from '@hooks/useConfirmModal'; -import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -19,7 +17,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Log from '@libs/Log'; -import goForwardInBrowserHistory from '@libs/Navigation/goForwardInBrowserHistory'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {getPayeeName} from '@libs/ReportUtils'; @@ -42,48 +39,6 @@ type DistanceRequestStartPageProps = WithWritableReportOrNotFoundProps, isDiscardModalOpenRef: React.RefObject) { - const getActiveGuardHasUnsavedChanges = useCallback(() => activeGuardRef.current?.getHasUnsavedChanges() ?? false, [activeGuardRef]); - const discardActiveGuardChanges = useCallback(() => activeGuardRef.current?.onDiscard(), [activeGuardRef]); - const cancelActiveGuardDiscard = useCallback(() => activeGuardRef.current?.onCancel?.(), [activeGuardRef]); - const setDiscardModalVisibility = useCallback( - (visible: boolean) => { - // eslint-disable-next-line no-param-reassign - isDiscardModalOpenRef.current = visible; - }, - [isDiscardModalOpenRef], - ); - - useDiscardChangesConfirmation({ - getHasUnsavedChanges: getActiveGuardHasUnsavedChanges, - onConfirm: discardActiveGuardChanges, - onCancel: cancelActiveGuardDiscard, - onVisibilityChange: setDiscardModalVisibility, - }); - - // Web browser-BACK moves the history pointer (popstate) before JS runs, so `beforeRemove` keeps the screen - // mounted but can't undo the URL - and since the transition is blocked, the hook's own `transitionStart` - // resync never fires. react-navigation then re-dispatches the blocked RESET, re-opening the modal on every - // cancel. Only browser-back produces a RESET here (header back = POP), so going forward on a - // RESET-with-unsaved-changes resyncs the URL and stops the loop. No-op on native. - useBeforeRemove( - useCallback( - (event) => { - if (event.data.action?.type !== CONST.NAVIGATION.ACTION_TYPE.RESET || !getActiveGuardHasUnsavedChanges()) { - return; - } - goForwardInBrowserHistory(); - }, - [getActiveGuardHasUnsavedChanges], - ), - ); -} - function DistanceRequestStartPage({ route, route: { @@ -224,9 +179,6 @@ function DistanceRequestStartPage({ [showConfirmModal, translate], ); - // Discard modal + browser-back resync for whichever distance tab is active (header / hardware / browser back). - useActiveTabGuardDiscardConfirmation(activeGuardRef, isDiscardModalOpenRef); - return ( boolean; onDiscard: () => void | Promise; - onCancel?: () => void; }; type RegisterDistanceTabGuard = (guard: DistanceTabGuard) => () => void; const DistanceTabGuardContext = createContext(null); -function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard: () => void | Promise, onCancel?: () => void) { +function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard: () => void | Promise) { const register = useContext(DistanceTabGuardContext); - const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard, onCancel}); + const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard}); useEffect(() => { - guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard, onCancel}; + guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard}; }); useEffect(() => { @@ -31,7 +30,6 @@ function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () = tabName, getHasUnsavedChanges: () => guardCallbacksRef.current.getHasUnsavedChanges(), onDiscard: () => guardCallbacksRef.current.onDiscard(), - onCancel: () => guardCallbacksRef.current.onCancel?.(), }); }, [register, tabName]); } diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 8824d4e0b467..3ac6042b7aa9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -72,23 +72,6 @@ type IOURequestStepDistanceOdometerProps = WithCurrentUserPersonalDetailsProps & transaction: OnyxEntry; }; -type StandaloneDiscardGuardProps = { - getHasUnsavedChanges: () => boolean; - onCancel: () => void; - onConfirm?: () => Promise; - onVisibilityChange: (visible: boolean) => void; -}; - -/** - * A component (not an inline hook) so the discard hook runs only on the standalone screen - in the - * `DISTANCE_CREATE` tab flow `DistanceRequestStartPage` owns discard, and running it here too would - * register a second `beforeRemove` listener and show the discard modal twice. - */ -function StandaloneDiscardGuard({getHasUnsavedChanges, onCancel, onConfirm, onVisibilityChange}: StandaloneDiscardGuardProps) { - useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onConfirm, onVisibilityChange}); - return null; -} - function IOURequestStepDistanceOdometer({ report, route: { @@ -589,7 +572,19 @@ function IOURequestStepDistanceOdometer({ }); }, []); - useRegisterDistanceTabGuard(CONST.TAB_REQUEST.DISTANCE_ODOMETER, getHasUnsavedChanges, handleTabSwitchDiscard, restoreLastInputFocus); + useRegisterDistanceTabGuard(CONST.TAB_REQUEST.DISTANCE_ODOMETER, getHasUnsavedChanges, handleTabSwitchDiscard); + + useDiscardChangesConfirmation({ + getHasUnsavedChanges, + onCancel: restoreLastInputFocus, + onConfirm: isEditingConfirmation + ? async () => { + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; + } + : undefined, + onVisibilityChange: setIsDiscardModalVisible, + }); const handleSaveForLater = useCallback(async () => { shouldBypassDiscardConfirmationRef.current = true; @@ -624,10 +619,6 @@ function IOURequestStepDistanceOdometer({ Navigation.closeRHPFlow(); }, [fromLocaleDigit, startReading, endReading, odometerStartImage, odometerEndImage, translate, setFormError]); - // Standalone screen only (edit / edit-from-confirmation); in the `DISTANCE_CREATE` tab flow `DistanceRequestStartPage` - // owns discard, so running it here too would show the modal twice. Rendered as a child since hooks can't be conditional - const isStandaloneScreen = routeName !== SCREENS.MONEY_REQUEST.DISTANCE_CREATE; - return ( - {isStandaloneScreen && ( - { - await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); - backupHandledManually.current = true; - } - : undefined - } - onVisibilityChange={setIsDiscardModalVisible} - /> - )} {/* Start Reading */} diff --git a/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts b/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts deleted file mode 100644 index d46d8c19dd8c..000000000000 --- a/tests/unit/Navigation/getTabHistoryReplacementRouter.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type {NavigationAction, ParamListBase, Router, RouterConfigOptions, TabNavigationState} from '@react-navigation/native'; -import {TabActions, TabRouter} from '@react-navigation/native'; -import getTabHistoryReplacementRouter, {createTabHistoryReplacementRouter} from '@libs/Navigation/getTabHistoryReplacementRouter'; -import CONST from '@src/CONST'; - -// Force the web behavior (flag is `false` on native, `true` on web) so the allowlist gating is -// deterministic regardless of the platform jest resolves `OnyxTabNavigatorConfig` to. -jest.mock('@libs/Navigation/OnyxTabNavigatorConfig', () => ({ - shouldReplaceTabHistoryOnTabSwitch: true, - defaultScreenOptions: {}, -})); - -const ROUTE_NAMES = ['map', 'manual', 'gps', 'odometer']; -const INITIAL_ROUTE = 'map'; - -const CONFIG_OPTIONS: RouterConfigOptions = { - routeNames: ROUTE_NAMES, - routeParamList: {}, - routeGetIdList: {}, -}; - -/** A real TabRouter configured like the distance-create navigator (initialRoute back behavior). */ -function buildOriginalRouter(): Router, NavigationAction> { - return TabRouter({initialRouteName: INITIAL_ROUTE, backBehavior: 'initialRoute'}) as unknown as Router, NavigationAction>; -} - -/** The same TabRouter with our history-clamping override merged on top. */ -function buildWrappedRouter() { - const original = buildOriginalRouter(); - return {...original, ...createTabHistoryReplacementRouter()(original)}; -} - -function getInitialState(router: Router, NavigationAction>): TabNavigationState { - return router.getInitialState(CONFIG_OPTIONS); -} - -function focusedRouteName(state: TabNavigationState): string | undefined { - return state.routes.at(state.index)?.name; -} - -describe('createTabHistoryReplacementRouter', () => { - it('clamps history to the focused route when switching from the initial tab to another tab', () => { - const wrapped = buildWrappedRouter(); - const initialState = getInitialState(wrapped); - - const nextState = wrapped.getStateForAction(initialState, TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; - - expect(nextState).not.toBeNull(); - expect(focusedRouteName(nextState)).toBe('odometer'); - // The whole point: history stays length 1 so useLinking does history.replace (no new browser entry). - expect(nextState.history).toHaveLength(1); - expect(nextState.history.at(0)?.key).toBe(nextState.routes.at(nextState.index)?.key); - }); - - it('matches the stock TabRouter except for the clamped history length', () => { - const original = buildOriginalRouter(); - const wrapped = buildWrappedRouter(); - const initialState = getInitialState(original); - - const originalNext = original.getStateForAction(initialState, TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; - const wrappedNext = wrapped.getStateForAction(initialState, TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; - - // Stock router grows history (initial -> non-initial == length 2); ours collapses it to 1. - expect(originalNext.history.length).toBeGreaterThan(1); - expect(wrappedNext.history).toHaveLength(1); - // Everything else (index / focused route) is identical. - expect(wrappedNext.index).toBe(originalNext.index); - expect(focusedRouteName(wrappedNext)).toBe(focusedRouteName(originalNext)); - }); - - it('keeps history at length 1 when switching between two non-initial tabs', () => { - const wrapped = buildWrappedRouter(); - const onOdometer = wrapped.getStateForAction(getInitialState(wrapped), TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; - - const onManual = wrapped.getStateForAction(onOdometer, TabActions.jumpTo('manual'), CONFIG_OPTIONS) as TabNavigationState; - - expect(focusedRouteName(onManual)).toBe('manual'); - expect(onManual.history).toHaveLength(1); - }); - - it('returns null for an in-navigator GO_BACK on the clamped history so back bubbles to the parent', () => { - const wrapped = buildWrappedRouter(); - const onOdometer = wrapped.getStateForAction(getInitialState(wrapped), TabActions.jumpTo('odometer'), CONFIG_OPTIONS) as TabNavigationState; - - const afterBack = wrapped.getStateForAction(onOdometer, {type: CONST.NAVIGATION.ACTION_TYPE.GO_BACK} as NavigationAction, CONFIG_OPTIONS); - - expect(afterBack).toBeNull(); - }); - - it('still matches the React Navigation TabRouter action type for tab switches', () => { - // Guards the action-type coupling: the history clamp only fires for actions in TAB_SWITCH_ACTION_TYPES, - // so if a RN upgrade renamed JUMP_TO the clamp would silently stop working - expect(TabActions.jumpTo('odometer').type).toBe(CONST.NAVIGATION.ACTION_TYPE.JUMP_TO); - }); - - it('does not touch state for non-tab-switch actions (passes the stock result through)', () => { - const original = buildOriginalRouter(); - const wrapped = buildWrappedRouter(); - const initialState = getInitialState(wrapped); - - const setParams = {type: 'SET_PARAMS', source: initialState.routes.at(0)?.key, payload: {params: {foo: 'bar'}}} as NavigationAction; - const originalNext = original.getStateForAction(initialState, setParams, CONFIG_OPTIONS); - const wrappedNext = wrapped.getStateForAction(initialState, setParams, CONFIG_OPTIONS); - - expect(wrappedNext).toEqual(originalNext); - }); -}); - -describe('getTabHistoryReplacementRouter', () => { - it('returns an override factory for the allowlisted distance navigator', () => { - expect(typeof getTabHistoryReplacementRouter(CONST.TAB.DISTANCE_REQUEST_TYPE)).toBe('function'); - }); - - it('returns undefined for navigators that are not allowlisted', () => { - expect(getTabHistoryReplacementRouter(CONST.TAB.IOU_REQUEST_TYPE)).toBeUndefined(); - expect(getTabHistoryReplacementRouter(CONST.TAB.SPLIT_EXPENSE_TAB_TYPE)).toBeUndefined(); - }); -}); From abef5e3a59fec8fd845e96f127ee6ef9dda28d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 12 Jun 2026 16:00:22 +0200 Subject: [PATCH 17/42] chore: prettier run --- .../iou/request/step/IOURequestStepDistance/odometerResync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts index 293e0ae725e4..a5b69a5a28ac 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts @@ -4,7 +4,7 @@ * * That effect keeps the local readings in sync with the transaction without clobbering in-progress typing, and * slides the discard-changes baseline when the transaction is changed elsewhere (e.g. an edit saved from the - * confirmation step). The branching lives here as small pure predicates that can be unit-tested in isolation + * confirmation step). The branching lives here as small pure predicates that can be unit-tested in isolation * from the effect's ref mutations */ From 08b559197de6800752166aaf333b81d047ac4bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 12 Jun 2026 16:37:09 +0200 Subject: [PATCH 18/42] chore: add reminted to cspell wordlist --- cspell.json | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.json b/cspell.json index 80ef1951f2c2..64ac632aa505 100644 --- a/cspell.json +++ b/cspell.json @@ -823,6 +823,7 @@ "reimbursability", "reimbursementid", "reimbursible", + "reminted", "remotedesktop", "remotesync", "removeHiddenElems", From 572d2e70f9fc552aa7b81e80acf2a706e38e8621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 15 Jun 2026 02:29:10 +0200 Subject: [PATCH 19/42] feat(navigation): add generic tab-switch discard guard to OnyxTabNavigator --- .../getDiscardChangesModalConfig.ts | 18 ++ .../index.native.ts | 18 +- .../useDiscardChangesConfirmation/index.ts | 16 +- .../useDiscardChangesConfirmation/types.ts | 7 + src/libs/Navigation/OnyxTabNavigator.tsx | 163 +++++++++++++----- src/libs/Navigation/TabSwitchGuardContext.tsx | 45 +++++ 6 files changed, 209 insertions(+), 58 deletions(-) create mode 100644 src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts create mode 100644 src/libs/Navigation/TabSwitchGuardContext.tsx diff --git a/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts new file mode 100644 index 000000000000..85af624b95eb --- /dev/null +++ b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts @@ -0,0 +1,18 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +/** + * Shared config for the "Discard changes?" confirmation modal so the nav-away (`useDiscardChangesConfirmation`) + * and tab-switch (`OnyxTabNavigator`) paths show the exact same modal. Web callers add the web-only + * `shouldIgnoreBackHandlerDuringTransition` themselves. + */ +function getDiscardChangesModalConfig(translate: LocaleContextProps['translate']) { + return { + title: translate('discardChangesConfirmation.title'), + prompt: translate('discardChangesConfirmation.body'), + danger: true, + confirmText: translate('discardChangesConfirmation.confirmText'), + cancelText: translate('common.cancel'), + }; +} + +export default getDiscardChangesModalConfig; diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index f44dd33c6231..b71ba86ce4cb 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -1,19 +1,25 @@ import type {NavigationAction} from '@react-navigation/native'; -import {usePreventRemove} from '@react-navigation/native'; +import {usePreventRemove, useRoute} from '@react-navigation/native'; 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 {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm, onTabSwitchDiscard}: UseDiscardChangesConfirmationOptions) { + const route = useRoute(); const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); const blockedNavigationAction = useRef(undefined); + // When rendered inside an OnyxTabNavigator tab, also guard tab switches (no-op otherwise / when no onTabSwitchDiscard given). + useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); + const shouldPrevent = !shouldAllowNavigation; usePreventRemove( @@ -27,13 +33,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi } blockedNavigationAction.current = data.action; onVisibilityChange?.(true); - showConfirmModal({ - title: translate('discardChangesConfirmation.title'), - prompt: translate('discardChangesConfirmation.body'), - danger: true, - confirmText: translate('discardChangesConfirmation.confirmText'), - cancelText: translate('common.cancel'), - }).then((result) => { + showConfirmModal(getDiscardChangesModalConfig(translate)).then((result) => { onVisibilityChange?.(false); if (result.action !== ModalActions.CONFIRM) { onCancel?.(); diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 7d3b5d5112f5..4ed87e4d8302 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 {useNavigation} from '@react-navigation/native'; +import {useNavigation, useRoute} from '@react-navigation/native'; import {useCallback, useEffect, useRef} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useBeforeRemove from '@hooks/useBeforeRemove'; @@ -10,13 +10,19 @@ import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNav import navigateAfterInteraction from '@libs/Navigation/navigateAfterInteraction'; import navigationRef from '@libs/Navigation/navigationRef'; import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; +import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; +import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm, onTabSwitchDiscard}: UseDiscardChangesConfirmationOptions) { const navigation = useNavigation>(); + const route = useRoute(); const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); + + // When rendered inside an OnyxTabNavigator tab, also guard tab switches (no-op otherwise / when no onTabSwitchDiscard given). + useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); const blockedNavigationAction = useRef(undefined); const shouldNavigateBack = useRef(false); const shouldIgnoreNextBeforeRemove = useRef(false); @@ -61,11 +67,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi isDiscardModalOpen.current = true; onVisibilityChange?.(true); showConfirmModal({ - title: translate('discardChangesConfirmation.title'), - prompt: translate('discardChangesConfirmation.body'), - danger: true, - confirmText: translate('discardChangesConfirmation.confirmText'), - cancelText: translate('common.cancel'), + ...getDiscardChangesModalConfig(translate), shouldIgnoreBackHandlerDuringTransition: true, }).then((result) => { markNextBeforeRemoveAsModalCleanup(); diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index abfcbc6b76fd..f6940df2a58a 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -3,6 +3,13 @@ type UseDiscardChangesConfirmationOptions = { onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; onConfirm?: () => void | Promise; + + /** + * Discard action to run when the user confirms leaving via a tab switch (as opposed to navigating away). + * Provide this when the hook is used inside an `OnyxTabNavigator` tab screen to also guard tab switches. + * It can differ from `onConfirm`: e.g. tab-switch clears the in-flow fields, while nav-away abandons the draft + */ + onTabSwitchDiscard?: () => void | Promise; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index b87a155c1653..27ea686bb63e 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -1,14 +1,19 @@ import type {MaterialTopTabNavigationEventMap} from '@react-navigation/material-top-tabs'; import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; -import type {EventMapCore, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; -import {useRoute} from '@react-navigation/native'; +import type {EventArg, EventMapCore, NavigationProp, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; +import {TabActions, useRoute} from '@react-navigation/native'; import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {TabSelectorProps} from '@components/TabSelector/types'; +import useConfirmModal from '@hooks/useConfirmModal'; +import getDiscardChangesModalConfig from '@hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig'; +import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import Log from '@libs/Log'; import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,6 +21,8 @@ import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {defaultScreenOptions} from './OnyxTabNavigatorConfig'; +import TabSwitchGuardContext from './TabSwitchGuardContext'; +import type {RegisterTabSwitchGuard, TabSwitchGuard} from './TabSwitchGuardContext'; type OnyxTabNavigatorProps = ChildrenProps & { /** ID of the tab component to be saved in onyx */ @@ -133,6 +140,64 @@ function OnyxTabNavigator({ }); }, []); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + // Tab-switch discard guards, keyed by tab name. Tab screens register via `useDiscardChangesConfirmation`. + const guardsRef = useRef>(new Map()); + const isDiscardModalOpenRef = useRef(false); + + const registerTabGuard = useCallback((guard) => { + guardsRef.current.set(guard.tabName, guard); + return () => { + // Only clear if this exact guard is still registered, so a re-registration from another mount isn't wiped. + if (guardsRef.current.get(guard.tabName) !== guard) { + return; + } + guardsRef.current.delete(guard.tabName); + }; + }, []); + + const handleTabPress = useCallback( + (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { + if (isDiscardModalOpenRef.current) { + event.preventDefault(); + return; + } + const navState = navigation.getState(); + const currentRouteName = navState.routes.at(navState.index)?.name; + const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; + if (!guard || !guard.getHasUnsavedChanges()) { + return; + } + const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); + if (!targetRoute || targetRoute.name === currentRouteName) { + return; + } + event.preventDefault(); + isDiscardModalOpenRef.current = true; + showConfirmModal({ + ...getDiscardChangesModalConfig(translate), + shouldIgnoreBackHandlerDuringTransition: true, + }).then((result) => { + isDiscardModalOpenRef.current = false; + if (result.action !== ModalActions.CONFIRM) { + guard.onCancel?.(); + return; + } + // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. + Promise.resolve() + .then(() => guard.onDiscard()) + .catch((error: unknown) => { + Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); + }) + .then(() => { + navigation.dispatch(TabActions.jumpTo(targetRoute.name)); + }); + }); + }, + [showConfirmModal, translate], + ); + /** * This is a TabBar wrapper component that includes the focus trap container element callback. * In `TabSelector.tsx` component, the callback prop to register focus trap container element is supported out of the box @@ -161,46 +226,60 @@ function OnyxTabNavigator({ } return ( - - { - const event = e as unknown as EventMapCore['state']; - const state = event.data.state; - const index = state.index; - const routeNames = state.routeNames; - if (isFirstMountRef.current) { - onTabSelect?.({index}); - isFirstMountRef.current = false; - } - const newSelectedTab = routeNames.at(index); - if (selectedTab === newSelectedTab) { - return; - } - if (newSelectedTab) { - Tab.setSelectedTab(id, newSelectedTab as TTabName); - } - onTabSelected(newSelectedTab as TTabName); - }, - ...(screenListeners ?? {}), - }} - screenOptions={{ - ...defaultScreenOptions, - swipeEnabled: false, - lazy: lazyLoadEnabled, - lazyPlaceholder: LazyPlaceholder, - }} - > - {children} - - + + + }) => { + const callerListeners = screenListeners ?? {}; + return { + ...callerListeners, + state: (e) => { + callerListeners.state?.(e); + const event = e as unknown as EventMapCore['state']; + const state = event.data.state; + const index = state.index; + const routeNames = state.routeNames; + if (isFirstMountRef.current) { + onTabSelect?.({index}); + isFirstMountRef.current = false; + } + const newSelectedTab = routeNames.at(index); + if (selectedTab === newSelectedTab) { + return; + } + if (newSelectedTab) { + Tab.setSelectedTab(id, newSelectedTab as TTabName); + } + onTabSelected(newSelectedTab as TTabName); + }, + tabPress: (e) => { + // Let a caller's own tabPress run first; if it blocked the switch, don't also run the guard. + callerListeners.tabPress?.(e); + if (e.defaultPrevented) { + return; + } + handleTabPress(navigation, e); + }, + }; + }} + screenOptions={{ + ...defaultScreenOptions, + swipeEnabled: false, + lazy: lazyLoadEnabled, + lazyPlaceholder: LazyPlaceholder, + }} + > + {children} + + + ); } diff --git a/src/libs/Navigation/TabSwitchGuardContext.tsx b/src/libs/Navigation/TabSwitchGuardContext.tsx new file mode 100644 index 000000000000..b8076000fe23 --- /dev/null +++ b/src/libs/Navigation/TabSwitchGuardContext.tsx @@ -0,0 +1,45 @@ +/** + * Lets a tab screen register discard callbacks that `OnyxTabNavigator` reads to show the "Discard changes?" + * modal when the user presses a different tab with unsaved changes. Guards are keyed by tab name, so each + * tab in a navigator can register its own without clobbering the others. + */ +import {createContext, useContext, useEffect, useRef} from 'react'; + +type TabSwitchGuard = { + tabName: string; + getHasUnsavedChanges: () => boolean; + onDiscard: () => void | Promise; + onCancel?: () => void; +}; + +type RegisterTabSwitchGuard = (guard: TabSwitchGuard) => () => void; + +const TabSwitchGuardContext = createContext(null); + +function useRegisterTabSwitchGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard?: () => void | Promise, onCancel?: () => void) { + const register = useContext(TabSwitchGuardContext); + // Refresh the closures every render so the stable guard registered below always calls the latest ones. + const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard, onCancel}); + + useEffect(() => { + guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard, onCancel}; + }); + + // Opt-in: only register when there's a tab provider and an onDiscard to run, so callers outside a tabbed flow are unaffected. + const canRegister = !!register && !!onDiscard; + useEffect(() => { + if (!register || !canRegister) { + return undefined; + } + return register({ + tabName, + getHasUnsavedChanges: () => guardCallbacksRef.current.getHasUnsavedChanges(), + onDiscard: () => guardCallbacksRef.current.onDiscard?.(), + onCancel: () => guardCallbacksRef.current.onCancel?.(), + }); + }, [register, tabName, canRegister]); +} + +export default TabSwitchGuardContext; +export {useRegisterTabSwitchGuard}; +export type {TabSwitchGuard, RegisterTabSwitchGuard}; From 3ec31e71e7b2bb8a892d5494721fc146c142c651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 15 Jun 2026 08:50:47 +0200 Subject: [PATCH 20/42] refactor(odometer): adopt generic tab guard, drop DistanceTabGuardContext --- .../iou/request/DistanceRequestStartPage.tsx | 208 ++++++------------ .../iou/request/DistanceTabGuardContext.tsx | 39 ---- .../step/IOURequestStepDistanceOdometer.tsx | 4 +- ...URequestStepDistanceOdometerBackupTest.tsx | 18 +- ...equestStepDistanceOdometerNextSyncTest.tsx | 2 +- ...questStepDistanceOdometerSflResumeTest.tsx | 16 +- 6 files changed, 82 insertions(+), 205 deletions(-) delete mode 100644 src/pages/iou/request/DistanceTabGuardContext.tsx diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index 379c2779578f..c9c8bab8dd90 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -1,14 +1,10 @@ -import {TabActions} from '@react-navigation/native'; -import type {EventArg} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; import type {TabSelectorProps} from '@components/TabSelector/types'; -import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -16,7 +12,6 @@ import useResetIOUType from '@hooks/useResetIOUType'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {getPayeeName} from '@libs/ReportUtils'; @@ -27,8 +22,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {SelectedTabRequest} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import DistanceTabGuardContext from './DistanceTabGuardContext'; -import type {DistanceTabGuard, RegisterDistanceTabGuard} from './DistanceTabGuardContext'; import IOURequestStepDistanceGPS from './step/IOURequestStepDistanceGPS'; import IOURequestStepDistanceManual from './step/IOURequestStepDistanceManual'; import IOURequestStepDistanceMap from './step/IOURequestStepDistanceMap'; @@ -105,79 +98,7 @@ function DistanceRequestStartPage({ return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); - const activeGuardRef = useRef(null); - const tabNavigationRef = useRef(null); - const tabStateRef = useRef(null); - const isDiscardModalOpenRef = useRef(false); - - const registerTabGuard = useCallback((guard) => { - activeGuardRef.current = guard; - return () => { - if (activeGuardRef.current?.tabName !== guard.tabName) { - return; - } - activeGuardRef.current = null; - }; - }, []); - - const {showConfirmModal} = useConfirmModal(); - - const tabBar = useCallback((tabBarProps: TabSelectorProps) => { - tabNavigationRef.current = tabBarProps.navigation; - tabStateRef.current = tabBarProps.state; - return ; - }, []); - - const screenListeners = useMemo( - () => ({ - tabPress: (event: EventArg<'tabPress', true, undefined>) => { - if (isDiscardModalOpenRef.current) { - event.preventDefault(); - return; - } - const guard = activeGuardRef.current; - const state = tabStateRef.current; - if (!guard || !state) { - return; - } - const currentRouteName = state.routes.at(state.index)?.name; - if (currentRouteName !== guard.tabName) { - return; - } - if (!guard.getHasUnsavedChanges()) { - return; - } - const targetRoute = state.routes.find((tabRoute) => tabRoute.key === event.target); - if (!targetRoute || targetRoute.name === currentRouteName) { - return; - } - event.preventDefault(); - isDiscardModalOpenRef.current = true; - showConfirmModal({ - title: translate('discardChangesConfirmation.title'), - prompt: translate('discardChangesConfirmation.body'), - danger: true, - confirmText: translate('discardChangesConfirmation.confirmText'), - cancelText: translate('common.cancel'), - shouldIgnoreBackHandlerDuringTransition: true, - }).then((result) => { - isDiscardModalOpenRef.current = false; - if (result.action !== ModalActions.CONFIRM) { - return; - } - Promise.resolve() - .then(() => guard.onDiscard()) - .then(() => { - tabNavigationRef.current?.dispatch(TabActions.jumpTo(targetRoute.name)); - }) - .catch((error: unknown) => { - Log.warn('[DistanceRequestStartPage] Failed to run onDiscard callback', {error}); - }); - }); - }, - }), - [showConfirmModal, translate], - ); + const tabBar = useCallback((tabBarProps: TabSelectorProps) => , []); return ( - - - - - - - - {() => ( - - - - )} - - - {() => ( - - - - )} - - - {() => ( - - - - )} - - - {() => ( - - - - )} - - - - + + + + + + + {() => ( + + + + )} + + + {() => ( + + + + )} + + + {() => ( + + + + )} + + + {() => ( + + + + )} + + + ); diff --git a/src/pages/iou/request/DistanceTabGuardContext.tsx b/src/pages/iou/request/DistanceTabGuardContext.tsx deleted file mode 100644 index 832431bcdb30..000000000000 --- a/src/pages/iou/request/DistanceTabGuardContext.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Lets the active distance tab register discard callbacks that `DistanceRequestStartPage` reads to show the - * "Discard changes?" modal on tab switch / back. Only one guard is active at a time. - */ -import {createContext, useContext, useEffect, useRef} from 'react'; - -type DistanceTabGuard = { - tabName: string; - getHasUnsavedChanges: () => boolean; - onDiscard: () => void | Promise; -}; - -type RegisterDistanceTabGuard = (guard: DistanceTabGuard) => () => void; - -const DistanceTabGuardContext = createContext(null); - -function useRegisterDistanceTabGuard(tabName: string, getHasUnsavedChanges: () => boolean, onDiscard: () => void | Promise) { - const register = useContext(DistanceTabGuardContext); - const guardCallbacksRef = useRef({getHasUnsavedChanges, onDiscard}); - - useEffect(() => { - guardCallbacksRef.current = {getHasUnsavedChanges, onDiscard}; - }); - - useEffect(() => { - if (!register) { - return undefined; - } - return register({ - tabName, - getHasUnsavedChanges: () => guardCallbacksRef.current.getHasUnsavedChanges(), - onDiscard: () => guardCallbacksRef.current.onDiscard(), - }); - }, [register, tabName]); -} - -export default DistanceTabGuardContext; -export {useRegisterDistanceTabGuard}; -export type {DistanceTabGuard, RegisterDistanceTabGuard}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 3ac6042b7aa9..bf60b3a1c816 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -46,7 +46,6 @@ import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; import {startSpan} from '@libs/telemetry/activeSpans'; -import {useRegisterDistanceTabGuard} from '@pages/iou/request/DistanceTabGuardContext'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -572,8 +571,6 @@ function IOURequestStepDistanceOdometer({ }); }, []); - useRegisterDistanceTabGuard(CONST.TAB_REQUEST.DISTANCE_ODOMETER, getHasUnsavedChanges, handleTabSwitchDiscard); - useDiscardChangesConfirmation({ getHasUnsavedChanges, onCancel: restoreLastInputFocus, @@ -583,6 +580,7 @@ function IOURequestStepDistanceOdometer({ backupHandledManually.current = true; } : undefined, + onTabSwitchDiscard: handleTabSwitchDiscard, onVisibilityChange: setIsDiscardModalVisible, }); diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx index 2e4dd8d5e5c1..725a66224008 100644 --- a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -9,8 +9,8 @@ import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersona import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; import * as TransactionEdit from '@libs/actions/TransactionEdit'; -import DistanceTabGuardContext from '@pages/iou/request/DistanceTabGuardContext'; -import type {DistanceTabGuard, RegisterDistanceTabGuard} from '@pages/iou/request/DistanceTabGuardContext'; +import TabSwitchGuardContext from '@libs/Navigation/TabSwitchGuardContext'; +import type {RegisterTabSwitchGuard, TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -115,7 +115,7 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), useFocusEffect: jest.fn(), usePreventRemove: jest.fn(), - useRoute: jest.fn(), + useRoute: jest.fn(() => ({key: 'distance-odometer', name: 'Money_Request_Step_Distance_Odometer', params: {}})), }; }); @@ -235,7 +235,7 @@ describe('IOURequestStepDistanceOdometer - edit-from-confirmation backup is rest }); // Integration tests for the discard guard: they capture the real closure registered via -// useRegisterDistanceTabGuard (through DistanceTabGuardContext) and call its getHasUnsavedChanges(). +// useRegisterTabSwitchGuard (through TabSwitchGuardContext) and call its getHasUnsavedChanges(). // // Setup is a save-for-later draft that holds the readings; the user then edits the image. The tests assert both // directions: @@ -260,11 +260,11 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); }); - function renderWithCapturedGuard(register: RegisterDistanceTabGuard) { + function renderWithCapturedGuard(register: RegisterTabSwitchGuard) { return render( - + - + , ); @@ -293,8 +293,8 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan if (startImage) { transaction.comment = {...transaction.comment, odometerStartImage: startImage}; } - let capturedGuard: DistanceTabGuard | undefined; - const register: RegisterDistanceTabGuard = (guard) => { + let capturedGuard: TabSwitchGuard | undefined; + const register: RegisterTabSwitchGuard = (guard) => { capturedGuard = guard; return () => {}; }; diff --git a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx index e1354a04807f..c4f38e7a0437 100644 --- a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx @@ -108,7 +108,7 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), useFocusEffect: jest.fn(), usePreventRemove: jest.fn(), - useRoute: jest.fn(), + useRoute: jest.fn(() => ({key: 'distance-odometer', name: 'Money_Request_Distance_Create', params: {}})), }; }); diff --git a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx index 18e538c83e22..1db71e5c7c7f 100644 --- a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx @@ -9,8 +9,8 @@ import Onyx from 'react-native-onyx'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import * as OdometerTransactionUtils from '@libs/actions/OdometerTransactionUtils'; -import DistanceTabGuardContext from '@pages/iou/request/DistanceTabGuardContext'; -import type {DistanceTabGuard, RegisterDistanceTabGuard} from '@pages/iou/request/DistanceTabGuardContext'; +import TabSwitchGuardContext from '@libs/Navigation/TabSwitchGuardContext'; +import type {RegisterTabSwitchGuard, TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -79,7 +79,7 @@ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), useFocusEffect: jest.fn(), usePreventRemove: jest.fn(), - useRoute: jest.fn(), + useRoute: jest.fn(() => ({key: 'distance-odometer', name: 'Money_Request_Distance_Create', params: {}})), })); const ACCOUNT_ID = 1; @@ -118,11 +118,11 @@ function createOdometerTransaction(withImage: boolean): Transaction { } as unknown as Transaction; } -function renderCreateFlow(register: RegisterDistanceTabGuard) { +function renderCreateFlow(register: RegisterTabSwitchGuard) { return render( - + - + , ); @@ -189,7 +189,7 @@ describe('IOURequestStepDistanceOdometer - create-flow discard guard (no stored await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, existingDraft); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, createOdometerTransaction(true)); }); - let resumeGuard: DistanceTabGuard | undefined; + let resumeGuard: TabSwitchGuard | undefined; renderCreateFlow((guard) => { resumeGuard = guard; return () => {}; @@ -208,7 +208,7 @@ describe('IOURequestStepDistanceOdometer - create-flow discard guard (no stored await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); }); - let guard: DistanceTabGuard | undefined; + let guard: TabSwitchGuard | undefined; renderCreateFlow((capturedGuard) => { guard = capturedGuard; return () => {}; From fd2b2fb3dce2a84b5d17a4c9886aa755f2d71ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 15 Jun 2026 21:25:58 +0200 Subject: [PATCH 21/42] chore(odometer): typed test factories, drop lint suppressions, trim comments --- config/eslint/eslint.seatbelt.tsv | 7 +- src/libs/actions/OdometerTransactionUtils.ts | 7 +- .../step/IOURequestStepDistanceOdometer.tsx | 12 +++- tests/actions/OdometerTransactionUtilsTest.ts | 72 ++++++++++--------- ...URequestStepDistanceOdometerBackupTest.tsx | 4 +- ...equestStepDistanceOdometerNextSyncTest.tsx | 2 +- ...questStepDistanceOdometerSflResumeTest.tsx | 2 +- 7 files changed, 58 insertions(+), 48 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index fe3a5e1d124d..fccbe5829d2b 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1621,7 +1621,6 @@ "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "no-restricted-imports" 1 "../../tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/actions/MergeTransactionTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 -"../../tests/actions/OdometerTransactionUtilsTest.ts" "@lwc/lwc/no-async-await" 4 "../../tests/actions/OdometerTransactionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/actions/OdometerTransactionUtilsTest.ts" "no-restricted-imports" 1 "../../tests/actions/OnyxUpdateManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 @@ -1703,9 +1702,9 @@ "../../tests/ui/ForYouSectionTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/ui/IOURequestStartPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/ui/IOURequestStepAmountDraftTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 6 -"../../tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 +"../../tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/ui/IOURequestStepDistanceTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/ui/IOURequestStepScanTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/ui/InitialSettingsPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index 6f42457ff70f..180d5fa40a48 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -249,11 +249,8 @@ type OdometerUnsavedChangesState = { /** * Decide whether the odometer screen has unsaved changes that warrant a "Discard changes?" prompt. - * - * The image guard is load-bearing: isOdometerDraftPendingHydration is directional (reports a transaction MISSING - * what the draft holds, never one AHEAD of it), so a genuine image add/swap reads as "not pending" and the draft - * clause would wrongly suppress it - hence that clause must not suppress when hasImageChanges is set. The identity - * is re-mint-invariant (name|size), so a non-user blob re-mint doesn't register. The typing guard does the same for readings + * Images diff on a re-mint-invariant identity (name|size) so a non-user blob re-mint doesn't register, and the + * typing guard does the same for readings. See the draft-suppression clause below for the directional caveat */ function getOdometerHasUnsavedChanges(state: OdometerUnsavedChangesState): boolean { if (!state.isGuardActive) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index bf60b3a1c816..2e0b0d3183f9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -241,15 +241,21 @@ function IOURequestStepDistanceOdometer({ initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; } - // The refs and setters destructured from `useOdometerReadingsState` are stable; we intentionally omit them - // to keep this effect driven only by transaction changes. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, currentTransaction?.comment?.odometerStartImage, currentTransaction?.comment?.odometerEndImage, isEditing, + setStartReading, + setEndReading, + startReadingRef, + endReadingRef, + initialStartReadingRef, + initialEndReadingRef, + initialStartImageRef, + initialEndImageRef, + hasInitializedRefs, ]); const navigateToNextStep = useOdometerNavigation({ diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 5fcb1f2250ec..ef838c3cc563 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -141,37 +141,41 @@ describe('actions/OdometerTransactionUtils', () => { afterEach(() => { jest.unmock('@libs/OdometerImageUtils'); }); - it('should set odometer start image on a draft transaction', async () => { + it('should set odometer start image on a draft transaction', () => { const transaction = createRandomTransaction(1); const transactionID = transaction.transactionID; const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); - - setMoneyRequestOdometerImage(transaction, imageType, file, true, false); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draftTransaction?.comment?.odometerStartImage).toEqual(file); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction) + .then(() => { + setMoneyRequestOdometerImage(transaction, imageType, file, true, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`)) + .then((draftTransaction) => { + expect(draftTransaction?.comment?.odometerStartImage).toEqual(file); + }); }); - it('should set odometer end image on a non-draft transaction', async () => { + it('should set odometer end image on a non-draft transaction', () => { const transaction = createRandomTransaction(1); const transactionID = transaction.transactionID; const file = {uri: 'image.uri', name: 'image.jpg', type: 'image/jpeg', size: 1234}; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - - setMoneyRequestOdometerImage(transaction, imageType, file, false, false); - await waitForBatchedUpdates(); - - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(updatedTransaction?.comment?.odometerEndImage).toEqual(file); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction) + .then(() => { + setMoneyRequestOdometerImage(transaction, imageType, file, false, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`)) + .then((updatedTransaction) => { + expect(updatedTransaction?.comment?.odometerEndImage).toEqual(file); + }); }); - it('should remove odometer start image from a draft transaction', async () => { + it('should remove odometer start image from a draft transaction', () => { const transaction = { ...createRandomTransaction(1), comment: { @@ -181,16 +185,18 @@ describe('actions/OdometerTransactionUtils', () => { const transactionID = transaction.transactionID; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction); - - removeMoneyRequestOdometerImage(transaction, imageType, true, false); - await waitForBatchedUpdates(); - - const draftTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); - expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, transaction) + .then(() => { + removeMoneyRequestOdometerImage(transaction, imageType, true, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`)) + .then((draftTransaction) => { + expect(draftTransaction?.comment?.odometerStartImage).toBeUndefined(); + }); }); - it('should remove odometer end image from a non-draft transaction', async () => { + it('should remove odometer end image from a non-draft transaction', () => { const transaction = { ...createRandomTransaction(1), comment: { @@ -200,13 +206,15 @@ describe('actions/OdometerTransactionUtils', () => { const transactionID = transaction.transactionID; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); - - removeMoneyRequestOdometerImage(transaction, imageType, false, false); - await waitForBatchedUpdates(); - - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); + return Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction) + .then(() => { + removeMoneyRequestOdometerImage(transaction, imageType, false, false); + return waitForBatchedUpdates(); + }) + .then(() => getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`)) + .then((updatedTransaction) => { + expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); + }); }); }); diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx index 725a66224008..4ed3fce71c54 100644 --- a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -161,7 +161,7 @@ function createOdometerTransaction(): Transaction { distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, }, }, - } as unknown as Transaction; + }; } // Edit-from-confirmation entry: route name !== DISTANCE_CREATE and action !== EDIT -> `isEditingConfirmation` is true @@ -223,7 +223,7 @@ describe('IOURequestStepDistanceOdometer - edit-from-confirmation backup is rest // The backup restore must run exactly once. With the duplicate inline effect it ran twice (the second // restore nulled the transaction). This assertion is 2 on the buggy code - expect(TransactionEdit.restoreOriginalTransactionFromBackupWithImageCleanup as jest.Mock).toHaveBeenCalledTimes(1); + expect(jest.mocked(TransactionEdit.restoreOriginalTransactionFromBackupWithImageCleanup)).toHaveBeenCalledTimes(1); // The expense must survive the back navigation: the draft transaction is restored from the backup, not nulled const restoredTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`); diff --git a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx index c4f38e7a0437..33a1cae5b541 100644 --- a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx @@ -152,7 +152,7 @@ function createOdometerDraftTransaction(): Transaction { distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, }, }, - } as unknown as Transaction; + }; } function renderCreateOdometer() { diff --git a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx index 1db71e5c7c7f..af6ca522deb9 100644 --- a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx @@ -115,7 +115,7 @@ function createOdometerTransaction(withImage: boolean): Transaction { odometerStartImage: withImage ? START_IMAGE : undefined, customUnit: {customUnitID: 'u', customUnitRateID: 'r', name: 'Distance', distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}, }, - } as unknown as Transaction; + }; } function renderCreateFlow(register: RegisterTabSwitchGuard) { From a6961d36b11849aa9fd891ae6b766654d198c553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 15 Jun 2026 21:55:58 +0200 Subject: [PATCH 22/42] chore(odometer): trim redundant and verbose comments --- src/libs/actions/OdometerTransactionUtils.ts | 3 --- .../hooks/useOdometerReadingsState.ts | 23 +++++-------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index 180d5fa40a48..3b1ff5150e64 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -20,9 +20,6 @@ type SaveOdometerDraftParams = { endImage?: FileObject | string | null; }; -/** - * Set the odometer readings for a transaction. - */ function setMoneyRequestOdometerReading(transactionID: string, startReading: number | null, endReading: number | null, isDraft: boolean) { Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { comment: { diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index e76ed70c5e26..df1ee23563b0 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -151,14 +151,9 @@ function useOdometerReadingsState({ const currentEnd = currentTransaction?.comment?.odometerEnd; const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; - // Snapshot the transaction as the baseline; the discard guard then diffs current-vs-baseline. No "user - // edited" tracking is needed: - // - Create: this runs at the (empty) mount before the user types, and the screen stays mounted across - // Next -> back, so the empty baseline survives and a committed-but-unsaved reading reads as a change - // - SFL-resume / reload: the fresh mount absorbs the hydrated value (= the saved state), so a clean resume - // stays silent - // - Images use a re-mint-invariant identity in the diff (getOdometerImageIdentity), so a non-user blob - // re-mint is absorbed while a genuine add/swap/remove is caught - no need to special-case the baseline + // Snapshot the transaction as the baseline; the discard guard diffs current-vs-baseline, so no "user edited" + // tracking is needed. The empty-mount baseline survives Next->back, an SFL-resume/reload absorbs the hydrated + // value, and images use a re-mint-invariant identity so only a genuine add/swap/remove counts. initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; @@ -177,19 +172,13 @@ function useOdometerReadingsState({ odometerDraft, ]); - // Initialize current state (NOT the initial refs, which are set once on mount) from the transaction when editing - // or when it has data - but not on tab switch. Mirrors the first three branches of - // `shouldInitializeOdometerFromTransaction` (odometerResync.ts is the source of truth), minus its `!isUserTyping` - // guard: the typing ref lives in the component, and this effect's deps are only readings + isEditing (no image - // deps), so it can't hit the clear-then-delete-image bug the guard fixes. Keep in sync. + // Sync current state (not the mount refs) from the transaction. Mirrors branches 1-3 of + // `shouldInitializeOdometerFromTransaction` (odometerResync.ts is the source of truth); it can safely omit that + // predicate's `!isUserTyping` guard because this effect's deps are readings + isEditing only (no image deps). useEffect(() => { const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; - // Only initialize if: - // 1. We haven't initialized yet AND transaction has data, OR - // 2. We're editing and transaction has data (to load existing values), OR - // 3. Transaction has data but local state is empty (user navigated back from another page) const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); const hasLocalState = startReadingRef.current || endReadingRef.current; From dd0253230545f49f8c15d7cd58548ef47a5df61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 16 Jun 2026 01:16:09 +0200 Subject: [PATCH 23/42] docs: condense comments in discard-changes confirmation and odometer flow --- .../getDiscardChangesModalConfig.ts | 6 +++--- .../index.native.ts | 3 ++- .../useDiscardChangesConfirmation/index.ts | 3 ++- .../useDiscardChangesConfirmation/types.ts | 5 ++--- src/libs/OdometerImageUtils.ts | 8 +++----- src/libs/actions/OdometerTransactionUtils.ts | 9 +++------ .../hooks/useOdometerReadingsState.ts | 17 +++++------------ .../IOURequestStepDistance/odometerResync.ts | 12 +++++------- .../step/IOURequestStepDistanceOdometer.tsx | 7 +++---- 9 files changed, 28 insertions(+), 42 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts index 85af624b95eb..203ca42c42af 100644 --- a/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts +++ b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts @@ -1,9 +1,9 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; /** - * Shared config for the "Discard changes?" confirmation modal so the nav-away (`useDiscardChangesConfirmation`) - * and tab-switch (`OnyxTabNavigator`) paths show the exact same modal. Web callers add the web-only - * `shouldIgnoreBackHandlerDuringTransition` themselves. + * Single source of truth for the "Discard changes?" modal content, so every caller (nav-away and tab-switch) + * renders an identical modal instead of drifting apart. Behavioral flags like the web-only + * `shouldIgnoreBackHandlerDuringTransition` are added per caller, not here */ function getDiscardChangesModalConfig(translate: LocaleContextProps['translate']) { return { diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index b71ba86ce4cb..0b3f3f64a133 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -17,7 +17,8 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); const blockedNavigationAction = useRef(undefined); - // When rendered inside an OnyxTabNavigator tab, also guard tab switches (no-op otherwise / when no onTabSwitchDiscard given). + // 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 shouldPrevent = !shouldAllowNavigation; diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 4ed87e4d8302..61e2ca7ba5c8 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -21,7 +21,8 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); - // When rendered inside an OnyxTabNavigator tab, also guard tab switches (no-op otherwise / when no onTabSwitchDiscard given). + // 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); diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index f6940df2a58a..37e932b84274 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -5,9 +5,8 @@ type UseDiscardChangesConfirmationOptions = { onConfirm?: () => void | Promise; /** - * Discard action to run when the user confirms leaving via a tab switch (as opposed to navigating away). - * Provide this when the hook is used inside an `OnyxTabNavigator` tab screen to also guard tab switches. - * It can differ from `onConfirm`: e.g. tab-switch clears the in-flow fields, while nav-away abandons the draft + * 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; }; diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index f4da9c583f67..433585fd1a2c 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -11,11 +11,9 @@ function getOdometerImageName(image: FileObject | string | null | undefined): st /** * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. - * - * A re-mint (base64 -> blob via `deserializeOdometerDraftImage` on resume/reload, or a blob revoke/recreate) - * keeps the file `name` and `size` but produces a fresh blob `uri`. So comparing `name|size` stays stable - * across a re-mint while a different file (different name and/or size) reads as a change. Native images are - * `file://` uri strings that never re-mint, so the string itself is already a stable identity + * Re-minting a blob (on resume/reload, or revoke/recreate) yields a fresh `uri` but keeps `name` and `size`, + * so `name|size` stays stable across re-mints while a different file changes it. Native images are `file://` + * strings that never re-mint, so the string itself is already a stable identity. */ function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { if (!image) { diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index 3b1ff5150e64..0ef3c79c3514 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -245,18 +245,15 @@ type OdometerUnsavedChangesState = { }; /** - * Decide whether the odometer screen has unsaved changes that warrant a "Discard changes?" prompt. - * Images diff on a re-mint-invariant identity (name|size) so a non-user blob re-mint doesn't register, and the - * typing guard does the same for readings. See the draft-suppression clause below for the directional caveat + * Decide whether the odometer screen has unsaved changes worth a "Discard changes?" prompt. Both checks ignore + * non-user noise: images diff on a re-mint-invariant identity (name|size), and the typing guard skips mid-edit readings */ function getOdometerHasUnsavedChanges(state: OdometerUnsavedChangesState): boolean { if (!state.isGuardActive) { return false; } const hasImageChanges = state.transactionStartImageUri !== state.baselineStartImageUri || state.transactionEndImageUri !== state.baselineEndImageUri; - // Suppress only when the transaction still EQUALS the draft (spurious baseline drift, not a genuine edit). - // isOdometerDraftPendingHydration is directional, so it can't catch a transaction that moved AHEAD of the draft - // (changed reading + Next, no re-save) - this reading-equality check and the typing guard do + // Suppress only when the transaction still EQUALS the draft (baseline drift is not a real edit). const draftReadingsMatchTransaction = (state.odometerDraft?.odometerStartReading ?? null) === (state.currentComment?.odometerStart ?? null) && (state.odometerDraft?.odometerEndReading ?? null) === (state.currentComment?.odometerEnd ?? null); diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index df1ee23563b0..9e0c4209192b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -126,8 +126,7 @@ function useOdometerReadingsState({ prevSelectedTabRef.current = selectedTab; }, [selectedTab, isLoadingSelectedTab]); - // Initialize initial values refs on mount for DiscardChangesConfirmation - // These should never be updated after mount - they represent the "baseline" state + // Snapshot the baseline refs once on mount for the discard-changes diff; never update them after. useEffect(() => { if (hasInitializedRefs.current) { return; @@ -137,13 +136,11 @@ function useOdometerReadingsState({ if (!isEditing && !isOdometerTransaction) { return; } - // Wait for blob verification - otherwise Cmd+R would snapshot a stale blob URI before - // useRestartOnOdometerImagesFailure swaps in a fresh one, and the diff would look like an edit. + // Wait for blob verification, else Cmd+R snapshots a stale blob URI that later gets swapped, and the diff looks like an edit. if (!hasVerifiedBlobs) { return; } - // Wait for the save-for-later draft to land in the transaction; otherwise post-hydration - // values would later look like unsaved changes against this baseline. + // Wait for the save-for-later draft to land in the transaction, else hydrated values later look like unsaved changes. if (isOdometerDraftPendingHydration(odometerDraft, currentTransaction?.comment)) { return; } @@ -151,9 +148,7 @@ function useOdometerReadingsState({ const currentEnd = currentTransaction?.comment?.odometerEnd; const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; - // Snapshot the transaction as the baseline; the discard guard diffs current-vs-baseline, so no "user edited" - // tracking is needed. The empty-mount baseline survives Next->back, an SFL-resume/reload absorbs the hydrated - // value, and images use a re-mint-invariant identity so only a genuine add/swap/remove counts. + // Snapshot the transaction as the baseline; the discard guard diffs current-vs-baseline, no edit tracking needed. initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; initialStartImageRef.current = currentTransaction?.comment?.odometerStartImage; @@ -172,9 +167,7 @@ function useOdometerReadingsState({ odometerDraft, ]); - // Sync current state (not the mount refs) from the transaction. Mirrors branches 1-3 of - // `shouldInitializeOdometerFromTransaction` (odometerResync.ts is the source of truth); it can safely omit that - // predicate's `!isUserTyping` guard because this effect's deps are readings + isEditing only (no image deps). + // Sync current state (not the mount refs) from the transaction useEffect(() => { const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts index a5b69a5a28ac..4de5385efcc4 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts @@ -2,10 +2,9 @@ * Pure decision helpers for the "resync local odometer readings from the transaction" effect in * `IOURequestStepDistanceOdometer` * - * That effect keeps the local readings in sync with the transaction without clobbering in-progress typing, and - * slides the discard-changes baseline when the transaction is changed elsewhere (e.g. an edit saved from the - * confirmation step). The branching lives here as small pure predicates that can be unit-tested in isolation - * from the effect's ref mutations + * That effect syncs local readings from the transaction without clobbering in-progress typing, and slides the + * discard-changes baseline when the transaction changes elsewhere (e.g. an edit saved from the confirmation step). + * Keeping the branching here as pure predicates lets it be unit-tested apart from the effect's ref mutations */ type OdometerResyncState = { @@ -40,9 +39,8 @@ type OdometerResyncState = { }; /** - * An "external resync" is when the transaction was changed somewhere else (e.g. an edit saved from the - * confirmation step) while nothing is being typed here - so the transaction becomes the new baseline. The - * typing guard prevents clobbering in-progress keystrokes that aren't in the transaction yet + * An "external resync": the transaction changed elsewhere (e.g. an edit saved from the confirmation step) while + * nothing is being typed here, so it becomes the new baseline. The typing guard avoids clobbering in-progress keystrokes. */ function isExternalOdometerResync(state: OdometerResyncState): boolean { if (!state.hasTransactionData || !state.hasInitialized || state.isUserTyping) { diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 2e0b0d3183f9..a69152333d1c 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -233,10 +233,8 @@ function IOURequestStepDistanceOdometer({ endReadingRef.current = endValue; } - // Slide the readings baseline up on a non-user change (draft hydration, an external save from confirmation) - // so leaving doesn't flag the externally-saved value as unsaved; the typing guard in isExternalOdometerResync - // protects in-progress keystrokes. Images are NOT slid: their diff uses a re-mint-invariant identity, so a - // re-mint never registers (nothing to absorb) and sliding would wrongly absorb a genuine swap + // Slide the readings baseline on a non-user change (draft hydration, external save) so leaving doesn't flag it as unsaved. + // Images aren't slid: their re-mint-invariant diff already ignores re-mints, and sliding would absorb a genuine swap. if (isExternalResync) { initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; @@ -329,6 +327,7 @@ function IOURequestStepDistanceOdometer({ return shouldShowSave ? translate('common.save') : translate('common.next'); })(); + // Per-keystroke validation: enforce format constraints and cap the max value const isOdometerInputValid = (text: string, previousText: string): boolean => { if (!text) { return true; From 6b7b41db685e2d69adfca0e543752f51ecbb584c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 16 Jun 2026 01:20:34 +0200 Subject: [PATCH 24/42] chore: prettier run --- src/hooks/useDiscardChangesConfirmation/index.native.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 0b3f3f64a133..ca1f09f878ac 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -17,7 +17,7 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); const blockedNavigationAction = useRef(undefined); - // Also guard tab switches when this screen is an OnyxTabNavigator tab. + // 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); From b32e0573aae65cd11b7dcb7945f0cec8a87764b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 16 Jun 2026 17:23:03 +0200 Subject: [PATCH 25/42] chore(odometer): toast on tab-switch discard failure, typed test factories Add a Growl.error toast in OnyxTabNavigator when a tab-switch onDiscard rejects (alongside the existing Log.warn + always-jump), so a failed reset isn't silent. Replace remaining no-unsafe-type-assertion casts in the odometer tests with typed factories: OdometerDraft literals are cast-free (all-optional), and Transaction fixtures build on createRandomTransaction. Removes the seatbelt rows for the Backup/UtilsTest/ReadingsState/TransactionBackup tests and trims the two DISTANCE_CREATE route casts to one each. --- config/eslint/eslint.seatbelt.tsv | 8 +--- src/libs/Navigation/OnyxTabNavigator.tsx | 2 + tests/actions/OdometerTransactionUtilsTest.ts | 24 ++++++----- ...URequestStepDistanceOdometerBackupTest.tsx | 40 ++++++++---------- ...equestStepDistanceOdometerNextSyncTest.tsx | 29 ++++++++----- ...questStepDistanceOdometerSflResumeTest.tsx | 29 ++++++++----- .../hooks/useOdometerReadingsState.test.ts | 42 +++++++++---------- .../useOdometerTransactionBackup.test.ts | 13 ++++-- 8 files changed, 102 insertions(+), 85 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index cb26ed4f3375..32f1f2e844ab 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1613,7 +1613,6 @@ "../../tests/actions/IOUTest/UpdateMoneyRequestTest.ts" "no-restricted-imports" 1 "../../tests/actions/IOUTest/UpdateMoneyRequestVendorTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/actions/MergeTransactionTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 -"../../tests/actions/OdometerTransactionUtilsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/actions/OdometerTransactionUtilsTest.ts" "no-restricted-imports" 1 "../../tests/actions/OnyxUpdateManagerTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/actions/PersonalDetailsTest.ts" "@typescript-eslint/no-unsafe-type-assertion" 10 @@ -1694,9 +1693,8 @@ "../../tests/ui/ForYouSectionTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/ui/IOURequestStartPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/ui/IOURequestStepAmountDraftTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 4 -"../../tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 +"../../tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/ui/IOURequestStepDistanceTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/ui/IOURequestStepScanTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/ui/InitialSettingsPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1998,9 +1996,7 @@ "../../tests/unit/hooks/useIsSidebarRouteActive.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/hooks/useLazyAsset.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/hooks/useNonPersonalCardList.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 -"../../tests/unit/hooks/useOdometerReadingsState.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/unit/hooks/useOdometerReceiptStitcherTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 -"../../tests/unit/hooks/useOdometerTransactionBackup.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 16 "../../tests/unit/hooks/usePendingConciergeResponse.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 "../../tests/unit/hooks/useReceiptTraining.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 27ea686bb63e..8fd0735d718b 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -13,6 +13,7 @@ import getDiscardChangesModalConfig from '@hooks/useDiscardChangesConfirmation/g import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import Growl from '@libs/Growl'; import Log from '@libs/Log'; import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; @@ -189,6 +190,7 @@ function OnyxTabNavigator({ .then(() => guard.onDiscard()) .catch((error: unknown) => { Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); + Growl.error(translate('common.genericErrorMessage')); }) .then(() => { navigation.dispatch(TabActions.jumpTo(targetRoute.name)); diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index ef838c3cc563..78fec0cfded9 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {OdometerDraft, Policy} from '@src/types/onyx'; +import type {Policy} from '@src/types/onyx'; import type Transaction from '@src/types/onyx/Transaction'; import currencyList from '../unit/currencyList.json'; import createRandomTransaction from '../utils/collections/transaction'; @@ -176,12 +176,14 @@ describe('actions/OdometerTransactionUtils', () => { }); it('should remove odometer start image from a draft transaction', () => { - const transaction = { - ...createRandomTransaction(1), + const base = createRandomTransaction(1); + const transaction: Transaction = { + ...base, comment: { + ...base.comment, odometerStartImage: {uri: 'image.uri'}, }, - } as Transaction; + }; const transactionID = transaction.transactionID; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.START; @@ -197,12 +199,14 @@ describe('actions/OdometerTransactionUtils', () => { }); it('should remove odometer end image from a non-draft transaction', () => { - const transaction = { - ...createRandomTransaction(1), + const base = createRandomTransaction(1); + const transaction: Transaction = { + ...base, comment: { + ...base.comment, odometerEndImage: {uri: 'image.uri'}, }, - } as Transaction; + }; const transactionID = transaction.transactionID; const imageType = CONST.IOU.ODOMETER_IMAGE_TYPE.END; @@ -279,7 +283,7 @@ describe('actions/OdometerTransactionUtils', () => { const STEADY: OdometerUnsavedChangesState = { isGuardActive: true, isUserTyping: false, - odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OdometerDraft, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, currentComment: {odometerStart: 100, odometerEnd: 250}, transactionStartImageUri: '', transactionEndImageUri: '', @@ -320,7 +324,7 @@ describe('actions/OdometerTransactionUtils', () => { expect( getOdometerHasUnsavedChanges( buildState({ - odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'} as unknown as OdometerDraft, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}, transactionStartImageUri: '', baselineStartImageUri: '', }), @@ -348,7 +352,7 @@ describe('actions/OdometerTransactionUtils', () => { expect( getOdometerHasUnsavedChanges( buildState({ - odometerDraft: {odometerStartReading: 100, odometerEndReading: 300} as unknown as OdometerDraft, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 300}, currentComment: {odometerStart: 100, odometerEnd: 400}, hasReadingChanges: true, }), diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx index 4ed3fce71c54..dbab40734cc2 100644 --- a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -9,8 +9,10 @@ import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersona import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; import * as TransactionEdit from '@libs/actions/TransactionEdit'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import TabSwitchGuardContext from '@libs/Navigation/TabSwitchGuardContext'; import type {RegisterTabSwitchGuard, TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -126,6 +128,20 @@ const TRANSACTION_ID = 'txn-odometer-backup-1'; const ODOMETER_START = 100; const ODOMETER_END = 300; +// Typed route for the odometer step, built against the single screen so `action`/`backToReport` need no casts. +function createOdometerRoute(): PlatformStackScreenProps['route'] { + return { + key: 'Money_Request_Step_Distance_Odometer-test', + name: SCREENS.MONEY_REQUEST.STEP_DISTANCE_ODOMETER, + params: { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + }, + }; +} + function createTestReport(): Report { return { reportID: REPORT_ID, @@ -170,17 +186,7 @@ function renderEditFromConfirmationOdometer() { @@ -266,17 +272,7 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan diff --git a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx index 33a1cae5b541..8c6e31cc2a15 100644 --- a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx @@ -9,6 +9,8 @@ import Onyx from 'react-native-onyx'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -155,22 +157,27 @@ function createOdometerDraftTransaction(): Transaction { }; } +// The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), +// so the params object can't be built without one assertion. +function createDistanceCreateRoute(): PlatformStackScreenProps['route'] { + return { + key: 'Money_Request_Distance_Create-test', + name: SCREENS.MONEY_REQUEST.DISTANCE_CREATE, + params: { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE], + }; +} + function renderCreateOdometer() { return render( diff --git a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx index af6ca522deb9..f76f0c863a8c 100644 --- a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx @@ -9,8 +9,10 @@ import Onyx from 'react-native-onyx'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import * as OdometerTransactionUtils from '@libs/actions/OdometerTransactionUtils'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import TabSwitchGuardContext from '@libs/Navigation/TabSwitchGuardContext'; import type {RegisterTabSwitchGuard, TabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import type {MoneyRequestNavigatorParamList} from '@libs/Navigation/types'; import IOURequestStepDistanceOdometer from '@pages/iou/request/step/IOURequestStepDistanceOdometer'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -118,23 +120,28 @@ function createOdometerTransaction(withImage: boolean): Transaction { }; } +// The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), +// so the params object can't be built without one assertion. +function createDistanceCreateRoute(): PlatformStackScreenProps['route'] { + return { + key: 'Money_Request_Distance_Create-test', + name: SCREENS.MONEY_REQUEST.DISTANCE_CREATE, + params: { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE], + }; +} + function renderCreateFlow(register: RegisterTabSwitchGuard) { return render( diff --git a/tests/unit/hooks/useOdometerReadingsState.test.ts b/tests/unit/hooks/useOdometerReadingsState.test.ts index c931f38da98f..f3c4f07262f1 100644 --- a/tests/unit/hooks/useOdometerReadingsState.test.ts +++ b/tests/unit/hooks/useOdometerReadingsState.test.ts @@ -2,25 +2,33 @@ import {act, renderHook} from '@testing-library/react-native'; import useOdometerReadingsState from '@pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState'; import CONST from '@src/CONST'; import type * as OnyxTypes from '@src/types/onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; const mockIsOdometerDraftPendingHydration = jest.fn(() => false); jest.mock('@libs/actions/OdometerTransactionUtils', () => ({ - isOdometerDraftPendingHydration: (...args: unknown[]) => mockIsOdometerDraftPendingHydration(...(args as Parameters)), + isOdometerDraftPendingHydration: () => mockIsOdometerDraftPendingHydration(), })); type Params = Parameters[0]; -const buildOdometerTransaction = (overrides: Partial = {}): OnyxTypes.Transaction => - ({ +const buildOdometerTransaction = ( + commentOverrides: Partial = {}, + iouRequestType: OnyxTypes.Transaction['iouRequestType'] = CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, +): OnyxTypes.Transaction => { + const transaction = createRandomTransaction(1); + return { + ...transaction, transactionID: 't1', - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + iouRequestType, comment: { + ...transaction.comment, odometerStart: 100, odometerEnd: 250, - ...overrides, + ...commentOverrides, }, - }) as unknown as OnyxTypes.Transaction; + }; +}; const baseParams: Params = { currentTransaction: buildOdometerTransaction(), @@ -67,7 +75,7 @@ describe('useOdometerReadingsState', () => { const {result} = renderHook(() => useOdometerReadingsState({ ...baseParams, - odometerDraft: {odometerStartReading: 999} as unknown as OnyxTypes.OdometerDraft, + odometerDraft: {odometerStartReading: 999}, }), ); @@ -82,7 +90,7 @@ describe('useOdometerReadingsState', () => { useOdometerReadingsState({ ...baseParams, currentTransaction: buildOdometerTransaction({odometerStart: 120, odometerEnd: 300}), - odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OnyxTypes.OdometerDraft, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, }), ); @@ -99,7 +107,7 @@ describe('useOdometerReadingsState', () => { useOdometerReadingsState({ ...baseParams, currentTransaction: buildOdometerTransaction(), - odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'} as unknown as OnyxTypes.OdometerDraft, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250, odometerStartImage: 'data:image/png;base64,xxx'}, }), ); @@ -116,7 +124,7 @@ describe('useOdometerReadingsState', () => { useOdometerReadingsState({ ...baseParams, currentTransaction: buildOdometerTransaction(), - odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OnyxTypes.OdometerDraft, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, }), ); @@ -135,7 +143,7 @@ describe('useOdometerReadingsState', () => { useOdometerReadingsState({ ...baseParams, currentTransaction: buildOdometerTransaction({odometerStartImage: startImage, odometerEndImage: endImage}), - odometerDraft: {odometerStartReading: 100, odometerEndReading: 250} as unknown as OnyxTypes.OdometerDraft, + odometerDraft: {odometerStartReading: 100, odometerEndReading: 250}, }), ); @@ -145,11 +153,7 @@ describe('useOdometerReadingsState', () => { }); it('skips initialization on a non-odometer transaction unless we are editing', () => { - const transaction = { - transactionID: 't1', - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, - comment: {odometerStart: 100, odometerEnd: 250}, - } as unknown as OnyxTypes.Transaction; + const transaction = buildOdometerTransaction({}, CONST.IOU.REQUEST_TYPE.DISTANCE); const {result} = renderHook(() => useOdometerReadingsState({...baseParams, currentTransaction: transaction, isEditing: false})); @@ -201,11 +205,7 @@ describe('useOdometerReadingsState', () => { // Create flow at the unit level: a fresh mount snapshots an EMPTY baseline, so readings typed + committed // via Next later differ from it and leaving prompts. The screen stays mounted across Next -> back, so it survives it('captures an empty baseline on a fresh create mount with no readings yet', () => { - const emptyTransaction = { - transactionID: 't1', - iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, - comment: {}, - } as unknown as OnyxTypes.Transaction; + const emptyTransaction = buildOdometerTransaction({odometerStart: undefined, odometerEnd: undefined}); const {result} = renderHook(() => useOdometerReadingsState({...baseParams, currentTransaction: emptyTransaction})); expect(result.current.hasInitializedRefs.current).toBe(true); diff --git a/tests/unit/hooks/useOdometerTransactionBackup.test.ts b/tests/unit/hooks/useOdometerTransactionBackup.test.ts index bec40a211bbc..21d81e3b3809 100644 --- a/tests/unit/hooks/useOdometerTransactionBackup.test.ts +++ b/tests/unit/hooks/useOdometerTransactionBackup.test.ts @@ -4,23 +4,28 @@ import useOdometerTransactionBackup from '@pages/iou/request/step/IOURequestStep import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; import getOnyxValue from '../../utils/getOnyxValue'; import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; const TRANSACTION_ID = 'odometer-backup-test'; -const buildOdometerTransaction = (overrides: Partial = {}): OnyxTypes.Transaction => - ({ +const buildOdometerTransaction = (commentOverrides: Partial = {}): OnyxTypes.Transaction => { + const transaction = createRandomTransaction(1); + return { + ...transaction, transactionID: TRANSACTION_ID, iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, amount: 1234, merchant: '5 mi', comment: { + ...transaction.comment, odometerStart: 100, odometerEnd: 250, - ...overrides, + ...commentOverrides, }, - }) as unknown as OnyxTypes.Transaction; + }; +}; type Params = Parameters[0]; From f4576b0f7388bb88c9c5b0cbbeaf0166f77671d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 16 Jun 2026 18:58:25 +0200 Subject: [PATCH 26/42] fix: include lastModified in odometer image identity to detect same-name/size swaps --- src/libs/OdometerImageUtils.ts | 8 +-- src/libs/actions/OdometerTransactionUtils.ts | 18 +++++-- src/types/onyx/OdometerDraft.ts | 6 +++ src/types/utils/Attachment.ts | 2 +- tests/actions/OdometerTransactionUtilsTest.ts | 51 ++++++++++++++++++- 5 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index 433585fd1a2c..0a8813e8aa12 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -11,9 +11,9 @@ function getOdometerImageName(image: FileObject | string | null | undefined): st /** * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. - * Re-minting a blob (on resume/reload, or revoke/recreate) yields a fresh `uri` but keeps `name` and `size`, - * so `name|size` stays stable across re-mints while a different file changes it. Native images are `file://` - * strings that never re-mint, so the string itself is already a stable identity. + * `uri` changes on every re-mint, but `name|size|lastModified` is preserved across the draft round-trip, so it + * stays stable on resume/reload while still changing for a real swap (`lastModified` disambiguates a different + * file that happens to share name + size). Native images are stable `file://` strings, so we use them directly */ function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { if (!image) { @@ -22,7 +22,7 @@ function getOdometerImageIdentity(image: FileObject | string | null | undefined) if (typeof image === 'string') { return image; } - return `${image.name ?? ''}|${image.size ?? ''}`; + return `${image.name ?? ''}|${image.size ?? ''}|${image.lastModified ?? ''}`; } function getOdometerImageType(image: FileObject | string | null | undefined): string | undefined { diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index 0ef3c79c3514..604bd76dea0c 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -47,6 +47,8 @@ function setMoneyRequestOdometerImage(transaction: OnyxEntry, image name: file.name, type: file.type, size: file.size, + // Part of the re-mint-invariant identity (getOdometerImageIdentity) so a swap to a same-name/same-size file is still detected + ...(file.lastModified !== undefined && {lastModified: file.lastModified}), }; const transactionID = transaction?.transactionID; const existingImage = transaction?.comment?.[imageKey]; @@ -120,7 +122,7 @@ async function serializeOdometerDraftImage(image: FileObject | string | null | u } } -function deserializeOdometerDraftImage(image: string | undefined, transactionID: string, imageType: OdometerImageType): FileObject | string | undefined { +function deserializeOdometerDraftImage(image: string | undefined, transactionID: string, imageType: OdometerImageType, lastModified?: number): FileObject | string | undefined { if (!image) { return undefined; } @@ -136,6 +138,8 @@ function deserializeOdometerDraftImage(image: string | undefined, transactionID: name: file.name, type: file.type, size: file.size, + // Restore the original `lastModified` (base64ToFile resets it to Date.now()) so the re-minted image keeps its identity + ...(lastModified !== undefined && {lastModified}), }; } catch (error) { Log.warn('Failed to deserialize odometer draft image from base64', {error}); @@ -152,11 +156,16 @@ async function saveOdometerDraft({startReading, endReading, startImage, endImage return; } + const startImageLastModified = typeof startImage === 'object' ? startImage?.lastModified : undefined; + const endImageLastModified = typeof endImage === 'object' ? endImage?.lastModified : undefined; + const odometerDraft: OdometerDraft = { ...(startReading !== undefined && {odometerStartReading: startReading}), ...(endReading !== undefined && {odometerEndReading: endReading}), ...(serializedStartImage && {odometerStartImage: serializedStartImage}), ...(serializedEndImage && {odometerEndImage: serializedEndImage}), + ...(serializedStartImage && startImageLastModified !== undefined && {odometerStartImageLastModified: startImageLastModified}), + ...(serializedEndImage && endImageLastModified !== undefined && {odometerEndImageLastModified: endImageLastModified}), }; await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, odometerDraft); @@ -174,8 +183,9 @@ function buildOdometerCommentFromDraft(transactionID: string, odometerDraft: Ony return; } - const startImage = deserializeOdometerDraftImage(odometerDraft.odometerStartImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.START) ?? null; - const endImage = deserializeOdometerDraftImage(odometerDraft.odometerEndImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.END) ?? null; + const startImage = + deserializeOdometerDraftImage(odometerDraft.odometerStartImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.START, odometerDraft.odometerStartImageLastModified) ?? null; + const endImage = deserializeOdometerDraftImage(odometerDraft.odometerEndImage, transactionID, CONST.IOU.ODOMETER_IMAGE_TYPE.END, odometerDraft.odometerEndImageLastModified) ?? null; // Free the previous blob URL before the merge drops the reference - covers both replace and wipe-to-null; helper no-ops if non-blob or unchanged. revokeOdometerImageUri(currentComment?.odometerStartImage, startImage); @@ -246,7 +256,7 @@ type OdometerUnsavedChangesState = { /** * Decide whether the odometer screen has unsaved changes worth a "Discard changes?" prompt. Both checks ignore - * non-user noise: images diff on a re-mint-invariant identity (name|size), and the typing guard skips mid-edit readings + * non-user noise: images diff on a re-mint-invariant identity (name|size|lastModified), and the typing guard skips mid-edit readings */ function getOdometerHasUnsavedChanges(state: OdometerUnsavedChangesState): boolean { if (!state.isGuardActive) { diff --git a/src/types/onyx/OdometerDraft.ts b/src/types/onyx/OdometerDraft.ts index 2d884da86c17..8a7735674870 100644 --- a/src/types/onyx/OdometerDraft.ts +++ b/src/types/onyx/OdometerDraft.ts @@ -13,6 +13,12 @@ type OdometerDraft = { /** Draft end image as base64 (web) or file URI (native) */ odometerEndImage?: string; + + /** `lastModified` of the start image, preserved so the re-minted image keeps its identity (getOdometerImageIdentity) */ + odometerStartImageLastModified?: number; + + /** `lastModified` of the end image, preserved so the re-minted image keeps its identity (getOdometerImageIdentity) */ + odometerEndImageLastModified?: number; }; export default OdometerDraft; diff --git a/src/types/utils/Attachment.ts b/src/types/utils/Attachment.ts index caf85af12f17..c67547ca21e2 100644 --- a/src/types/utils/Attachment.ts +++ b/src/types/utils/Attachment.ts @@ -7,6 +7,6 @@ type ImagePickerResponse = { width?: number; }; -type FileObject = Partial & {getAsFile?: () => File | null}; +type FileObject = Partial & {getAsFile?: () => File | null; lastModified?: number}; export type {FileObject, ImagePickerResponse}; diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 78fec0cfded9..7b77708077cb 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -2,7 +2,13 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import '@libs/actions/IOU/MoneyRequest'; -import {getOdometerHasUnsavedChanges, isOdometerDraftPendingHydration, removeMoneyRequestOdometerImage, setMoneyRequestOdometerImage} from '@libs/actions/OdometerTransactionUtils'; +import { + getOdometerHasUnsavedChanges, + isOdometerDraftPendingHydration, + removeMoneyRequestOdometerImage, + saveOdometerDraft, + setMoneyRequestOdometerImage, +} from '@libs/actions/OdometerTransactionUtils'; import type {OdometerUnsavedChangesState} from '@libs/actions/OdometerTransactionUtils'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; @@ -222,6 +228,33 @@ describe('actions/OdometerTransactionUtils', () => { }); }); + describe('saveOdometerDraft', () => { + afterEach(() => Onyx.set(ONYXKEYS.ODOMETER_DRAFT, null)); + + // The image's lastModified must be persisted alongside the serialized image so the re-minted image can + // restore it and keep its identity stable across the draft round-trip. + it('persists the image lastModified into the draft', () => { + const startImage = {uri: 'blob:start', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1700000000000}; + + return saveOdometerDraft({startImage}) + .then(() => getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)) + .then((odometerDraft) => { + expect(odometerDraft?.odometerStartImageLastModified).toBe(1700000000000); + }); + }); + + // A native/string image (or one without lastModified) must not write a stray lastModified field. + it('omits lastModified when the image does not carry one', () => { + const startImage = {uri: 'blob:start', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + + return saveOdometerDraft({startImage}) + .then(() => getOnyxValue(ONYXKEYS.ODOMETER_DRAFT)) + .then((odometerDraft) => { + expect(odometerDraft?.odometerStartImageLastModified).toBeUndefined(); + }); + }); + }); + describe('isOdometerDraftPendingHydration (directional)', () => { it('returns false when there is no draft (no save-for-later flow)', () => { expect(isOdometerDraftPendingHydration(undefined, {odometerStart: 120, odometerEnd: 300})).toBe(false); @@ -404,6 +437,22 @@ describe('actions/OdometerTransactionUtils', () => { expect(getOdometerImageIdentity(b)).not.toBe(getOdometerImageIdentity(a)); }); + // Two genuinely different files can share the same filename AND byte size; lastModified disambiguates them so + // the discard guard still fires on the swap (the collision the name|size-only identity missed). + it('changes when the user swaps to a same-name/same-size file with a different lastModified', () => { + const a = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + const b = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 2000}; + expect(getOdometerImageIdentity(b)).not.toBe(getOdometerImageIdentity(a)); + }); + + // lastModified is preserved across the draft round-trip, so a re-mint that keeps name + size + lastModified + // (only the uri changes) must stay invariant - the discard guard must not fire on a resume/reload. + it('is invariant under a re-mint that keeps name + size + lastModified (new uri only)', () => { + const original = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + const reminted = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + expect(getOdometerImageIdentity(reminted)).toBe(getOdometerImageIdentity(original)); + }); + it('uses the uri string directly for native images', () => { expect(getOdometerImageIdentity('file:///path/to/a.jpg')).toBe('file:///path/to/a.jpg'); }); From 642ec62bb46d44662a9e0849a2e77253eeb36353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 18 Jun 2026 18:42:43 +0200 Subject: [PATCH 27/42] chore(lint): revert manual eslint-seatbelt baseline edits --- config/eslint/eslint.seatbelt.tsv | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index c22007b4e428..e0d8e6c03f1d 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1692,8 +1692,6 @@ "../../tests/ui/ForYouSectionTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3 "../../tests/ui/IOURequestStartPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/ui/IOURequestStepAmountDraftTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 -"../../tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../tests/ui/IOURequestStepDistanceTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 9 "../../tests/ui/IOURequestStepScanTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/ui/InitialSettingsPageTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 @@ -1995,6 +1993,7 @@ "../../tests/unit/hooks/useIsSidebarRouteActive.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../tests/unit/hooks/useLazyAsset.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/hooks/useNonPersonalCardList.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 3 +"../../tests/unit/hooks/useOdometerReadingsState.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 4 "../../tests/unit/hooks/useOdometerReceiptStitcherTest.tsx" "@typescript-eslint/no-unsafe-type-assertion" 5 "../../tests/unit/hooks/useOtherFeedsForFeedSelector.test.tsx" "@typescript-eslint/no-unsafe-type-assertion" 16 "../../tests/unit/hooks/usePendingConciergeResponse.test.ts" "@typescript-eslint/no-unsafe-type-assertion" 6 From 20b3614fd79f54790798ae35e73ae737588a4416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Fri, 19 Jun 2026 11:52:02 +0200 Subject: [PATCH 28/42] chore(lint): satisfy no-unsafe-type-assertion in odometer test helper --- ...URequestStepDistanceOdometerNextSyncTest.tsx | 17 +++++++++-------- ...RequestStepDistanceOdometerSflResumeTest.tsx | 17 +++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx index 8c6e31cc2a15..6f5e11843566 100644 --- a/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx @@ -157,18 +157,19 @@ function createOdometerDraftTransaction(): Transaction { }; } -// The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), -// so the params object can't be built without one assertion. function createDistanceCreateRoute(): PlatformStackScreenProps['route'] { + // The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), so the params object can't be built without one assertion. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see comment above + const params = { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE]; return { key: 'Money_Request_Distance_Create-test', name: SCREENS.MONEY_REQUEST.DISTANCE_CREATE, - params: { - action: CONST.IOU.ACTION.CREATE, - iouType: CONST.IOU.TYPE.SUBMIT, - reportID: REPORT_ID, - transactionID: TRANSACTION_ID, - } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE], + params, }; } diff --git a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx index f76f0c863a8c..df5607e4fd86 100644 --- a/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx @@ -120,18 +120,19 @@ function createOdometerTransaction(withImage: boolean): Transaction { }; } -// The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), -// so the params object can't be built without one assertion. function createDistanceCreateRoute(): PlatformStackScreenProps['route'] { + // The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), so the params object can't be built without one assertion. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see comment above + const params = { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE]; return { key: 'Money_Request_Distance_Create-test', name: SCREENS.MONEY_REQUEST.DISTANCE_CREATE, - params: { - action: CONST.IOU.ACTION.CREATE, - iouType: CONST.IOU.TYPE.SUBMIT, - reportID: REPORT_ID, - transactionID: TRANSACTION_ID, - } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE], + params, }; } From 86b587d1782e338e962f9b4f2c1eef1920fa2ba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 22 Jun 2026 13:45:37 +0200 Subject: [PATCH 29/42] refactor(odometer): drop dead isEditingConfirmation branch in tab-switch discard --- .../request/step/IOURequestStepDistanceOdometer.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index a69152333d1c..142b137183b7 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -557,15 +557,10 @@ function IOURequestStepDistanceOdometer({ hasReadingChanges: startReadingRef.current !== initialStartReadingRef.current || endReadingRef.current !== initialEndReadingRef.current, }); - const handleTabSwitchDiscard = async () => { - if (isEditingConfirmation) { - await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); - backupHandledManually.current = true; - } else { - setMoneyRequestOdometerReading(transactionID, null, null, isTransactionDraft); - removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, isTransactionDraft, true); - removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.END, isTransactionDraft, true); - } + const handleTabSwitchDiscard = () => { + setMoneyRequestOdometerReading(transactionID, null, null, isTransactionDraft); + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, isTransactionDraft, true); + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.END, isTransactionDraft, true); resetOdometerLocalState(); setFormError(''); }; From ba31d18c9e7161822a335307241696626fdf927c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Mon, 22 Jun 2026 14:44:35 +0200 Subject: [PATCH 30/42] refactor(odometer): consolidate duplicate resync effects into useOdometerReadingsState --- .../hooks/useOdometerReadingsState.ts | 73 +++++++++++++------ .../step/IOURequestStepDistanceOdometer.tsx | 61 +--------------- .../hooks/useOdometerReadingsState.test.ts | 35 +++++++++ 3 files changed, 88 insertions(+), 81 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index 9e0c4209192b..011d1b1b223e 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -1,9 +1,12 @@ import {useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; +import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import CONST from '@src/CONST'; import type {OdometerDraft, Transaction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; +import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; type UseOdometerReadingsStateParams = { /** The transaction whose odometer values seed and re-sync the local form state. */ @@ -23,6 +26,9 @@ type UseOdometerReadingsStateParams = { /** Save-for-later draft, if any - used to defer the initial-refs snapshot until the draft has hydrated into the transaction. */ odometerDraft: OnyxEntry; + + /** Tracks whether the user has typed changes not yet written to the transaction - guards the resync from clobbering in-progress keystrokes. */ + userHasUnsavedTypingRef: React.RefObject; }; type UseOdometerReadingsStateResult = { @@ -79,6 +85,7 @@ function useOdometerReadingsState({ isLoadingSelectedTab, hasVerifiedBlobs, odometerDraft, + userHasUnsavedTypingRef, }: UseOdometerReadingsStateParams): UseOdometerReadingsStateResult { const [startReading, setStartReading] = useState(''); const [endReading, setEndReading] = useState(''); @@ -167,32 +174,56 @@ function useOdometerReadingsState({ odometerDraft, ]); - // Sync current state (not the mount refs) from the transaction + // Reconcile local readings with the transaction (the source of truth), without the mount refs. + // Two jobs: (1) re-hydrate the inputs from the transaction when it should drive them (mount/edit/navigate-back), + // never clobbering in-progress typing; (2) slide the discard-changes baseline on a non-user (external) change. useEffect(() => { const currentStart = currentTransaction?.comment?.odometerStart; const currentEnd = currentTransaction?.comment?.odometerEnd; + const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; + const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; - const hasTransactionData = (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined); - const hasLocalState = startReadingRef.current || endReadingRef.current; - - const shouldInitialize = - (!hasInitializedRefs.current && hasTransactionData) || - (isEditing && hasTransactionData && !hasLocalState) || - (hasTransactionData && !hasLocalState && hasInitializedRefs.current); - - if (shouldInitialize) { - const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; - const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; - - if (startValue || endValue) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncing local form state from the Onyx transaction (an external source) when the user navigates back to the page - setStartReading(startValue); - setEndReading(endValue); - startReadingRef.current = startValue; - endReadingRef.current = endValue; - } + const resyncState: OdometerResyncState = { + transactionStartValue: startValue, + transactionEndValue: endValue, + localStartValue: startReadingRef.current, + localEndValue: endReadingRef.current, + transactionStartImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerStartImage), + transactionEndImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerEndImage), + baselineStartImageUri: getOdometerImageIdentity(initialStartImageRef.current), + baselineEndImageUri: getOdometerImageIdentity(initialEndImageRef.current), + hasTransactionData: (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined), + hasLocalState: !!(startReadingRef.current || endReadingRef.current), + hasInitialized: hasInitializedRefs.current, + isUserTyping: userHasUnsavedTypingRef.current, + isEditing, + }; + + const isExternalResync = isExternalOdometerResync(resyncState); + + // Sync the local readings up from the transaction (but never clobber in-progress typing) + if (shouldInitializeOdometerFromTransaction(resyncState, isExternalResync) && (startValue || endValue)) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncing local form state from the Onyx transaction (an external source) when the user navigates back to the page + setStartReading(startValue); + setEndReading(endValue); + startReadingRef.current = startValue; + endReadingRef.current = endValue; } - }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing]); + + // Slide the readings baseline on a non-user change (draft hydration, external save) so leaving doesn't flag it as unsaved. + // Images aren't slid: their re-mint-invariant diff already ignores re-mints, and sliding would absorb a genuine swap. + if (isExternalResync) { + initialStartReadingRef.current = startValue; + initialEndReadingRef.current = endValue; + } + }, [ + currentTransaction?.comment?.odometerStart, + currentTransaction?.comment?.odometerEnd, + currentTransaction?.comment?.odometerStartImage, + currentTransaction?.comment?.odometerEndImage, + isEditing, + userHasUnsavedTypingRef, + ]); return { startReading, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 142b137183b7..88e2abae907b 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -58,8 +58,6 @@ import useOdometerImageHandlers from './IOURequestStepDistance/hooks/useOdometer import useOdometerNavigation from './IOURequestStepDistance/hooks/useOdometerNavigation'; import useOdometerReadingsState from './IOURequestStepDistance/hooks/useOdometerReadingsState'; import useOdometerTransactionBackup from './IOURequestStepDistance/hooks/useOdometerTransactionBackup'; -import type {OdometerResyncState} from './IOURequestStepDistance/odometerResync'; -import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from './IOURequestStepDistance/odometerResync'; import StepScreenWrapper from './StepScreenWrapper'; import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; @@ -180,7 +178,7 @@ function IOURequestStepDistanceOdometer({ initialEndImageRef, resetOdometerLocalState, hasInitializedRefs, - } = useOdometerReadingsState({currentTransaction, isEditing, selectedTab, isLoadingSelectedTab, hasVerifiedBlobs, odometerDraft}); + } = useOdometerReadingsState({currentTransaction, isEditing, selectedTab, isLoadingSelectedTab, hasVerifiedBlobs, odometerDraft, userHasUnsavedTypingRef}); useEffect(() => { resetOdometerLocalStateRef.current = resetOdometerLocalState; @@ -199,63 +197,6 @@ function IOURequestStepDistanceOdometer({ backupHandledManuallyRef: backupHandledManually, }); - // Initialize values from transaction when editing or when transaction has data (but not when switching tabs) - // This updates the current state, but NOT the initial refs (those are set only once on mount) - useEffect(() => { - const currentStart = currentTransaction?.comment?.odometerStart; - const currentEnd = currentTransaction?.comment?.odometerEnd; - const startValue = currentStart !== null && currentStart !== undefined ? currentStart.toString() : ''; - const endValue = currentEnd !== null && currentEnd !== undefined ? currentEnd.toString() : ''; - - const resyncState: OdometerResyncState = { - transactionStartValue: startValue, - transactionEndValue: endValue, - localStartValue: startReadingRef.current, - localEndValue: endReadingRef.current, - transactionStartImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerStartImage), - transactionEndImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerEndImage), - baselineStartImageUri: getOdometerImageIdentity(initialStartImageRef.current), - baselineEndImageUri: getOdometerImageIdentity(initialEndImageRef.current), - hasTransactionData: (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined), - hasLocalState: !!(startReadingRef.current || endReadingRef.current), - hasInitialized: hasInitializedRefs.current, - isUserTyping: userHasUnsavedTypingRef.current, - isEditing, - }; - - const isExternalResync = isExternalOdometerResync(resyncState); - - // Sync the local readings up from the transaction (but never clobber in-progress typing) - if (shouldInitializeOdometerFromTransaction(resyncState, isExternalResync) && (startValue || endValue)) { - setStartReading(startValue); - setEndReading(endValue); - startReadingRef.current = startValue; - endReadingRef.current = endValue; - } - - // Slide the readings baseline on a non-user change (draft hydration, external save) so leaving doesn't flag it as unsaved. - // Images aren't slid: their re-mint-invariant diff already ignores re-mints, and sliding would absorb a genuine swap. - if (isExternalResync) { - initialStartReadingRef.current = startValue; - initialEndReadingRef.current = endValue; - } - }, [ - currentTransaction?.comment?.odometerStart, - currentTransaction?.comment?.odometerEnd, - currentTransaction?.comment?.odometerStartImage, - currentTransaction?.comment?.odometerEndImage, - isEditing, - setStartReading, - setEndReading, - startReadingRef, - endReadingRef, - initialStartReadingRef, - initialEndReadingRef, - initialStartImageRef, - initialEndImageRef, - hasInitializedRefs, - ]); - const navigateToNextStep = useOdometerNavigation({ iouType, action, diff --git a/tests/unit/hooks/useOdometerReadingsState.test.ts b/tests/unit/hooks/useOdometerReadingsState.test.ts index f3c4f07262f1..a72117ab996b 100644 --- a/tests/unit/hooks/useOdometerReadingsState.test.ts +++ b/tests/unit/hooks/useOdometerReadingsState.test.ts @@ -37,6 +37,7 @@ const baseParams: Params = { isLoadingSelectedTab: false, hasVerifiedBlobs: true, odometerDraft: undefined, + userHasUnsavedTypingRef: {current: false}, }; describe('useOdometerReadingsState', () => { @@ -55,6 +56,40 @@ describe('useOdometerReadingsState', () => { expect(result.current.endReadingRef.current).toBe('250'); }); + // After mount the transaction changes elsewhere (e.g. an edit saved from the confirmation step) while nothing is + // being typed here. The reconcile effect re-syncs the inputs AND slides the baseline so leaving won't flag it as unsaved. + it('on an external transaction change (not typing), re-syncs readings and slides the baseline', () => { + const {result, rerender} = renderHook((params: Params) => useOdometerReadingsState(params), {initialProps: baseParams}); + + expect(result.current.startReading).toBe('100'); + expect(result.current.initialStartReadingRef.current).toBe('100'); + + rerender({...baseParams, currentTransaction: buildOdometerTransaction({odometerStart: 120, odometerEnd: 300})}); + + expect(result.current.startReading).toBe('120'); + expect(result.current.endReading).toBe('300'); + // Baseline slides with the external change, so the discard diff stays clean + expect(result.current.initialStartReadingRef.current).toBe('120'); + expect(result.current.initialEndReadingRef.current).toBe('300'); + }); + + // The same external change while the user is mid-typing must NOT clobber their input or move the baseline. + it('does not re-sync or slide the baseline on an external change while the user is typing', () => { + const typingRef = {current: false}; + const {result, rerender} = renderHook((params: Params) => useOdometerReadingsState(params), { + initialProps: {...baseParams, userHasUnsavedTypingRef: typingRef}, + }); + + act(() => { + typingRef.current = true; + }); + + rerender({...baseParams, userHasUnsavedTypingRef: typingRef, currentTransaction: buildOdometerTransaction({odometerStart: 120, odometerEnd: 300})}); + + expect(result.current.startReading).toBe('100'); + expect(result.current.initialStartReadingRef.current).toBe('100'); + }); + it('captures initial baseline refs once blobs are verified and no draft is pending', () => { const {result} = renderHook(() => useOdometerReadingsState(baseParams)); From 3dbe51e8332b800d4f362f9c34f949558788c82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 23 Jun 2026 16:59:34 +0200 Subject: [PATCH 31/42] fix(odometer): show discard-changes prompt on tab switch after reload On reload, the odometer draft's blob image URLs are dead, so the blob-failure recovery in useRestartOnOdometerImagesFailure runs. It was latching two flags that left the discard-changes guard permanently disabled, so editing a reading and switching tabs silently lost the change (and, once the prompt did show, Discard left the readings empty while images stayed). - Mark blob verification passed when re-hydrating a save-for-later draft so the baseline snapshot can initialize (hasVerifiedBlobs no longer latches false). - Route the recovery's "backup handled" signal through a dedicated recoveryHandledBackupRef, read only by useOdometerTransactionBackup, so it no longer disables the discard-changes guard. - Reset userHasUnsavedTypingRef on tab-switch discard so the re-hydrated draft readings repopulate the inputs instead of showing empty. --- .../index.ts | 2 + .../hooks/useOdometerTransactionBackup.ts | 6 +- .../step/IOURequestStepDistanceOdometer.tsx | 8 +- ...URequestStepDistanceOdometerBackupTest.tsx | 48 ++++++- .../useOdometerTransactionBackup.test.ts | 25 ++++ .../useRestartOnOdometerImagesFailure.test.ts | 126 ++++++++++++++++++ 6 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts diff --git a/src/hooks/useRestartOnOdometerImagesFailure/index.ts b/src/hooks/useRestartOnOdometerImagesFailure/index.ts index 15161ac30274..75b5e13edc25 100644 --- a/src/hooks/useRestartOnOdometerImagesFailure/index.ts +++ b/src/hooks/useRestartOnOdometerImagesFailure/index.ts @@ -103,6 +103,8 @@ const useRestartOnOdometerImagesFailure = ( // Rehydrate over the dead URLs when a draft exists — clearing first races the destination's // auto-hydrator and ends up dropping the wrong URL. if (odometerDraft) { + // Restore images from draft, mark verified, and tell the backup hook not to revert on unmount + setAsyncVerificationPassed(true); onBackupHandled?.({shouldResetLocalState: false}); hydrateOdometerDraftIntoTransaction(transaction.transactionID, odometerDraft, transaction.comment); } else { diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerTransactionBackup.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerTransactionBackup.ts index a54eeefbbb35..006bd278f144 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerTransactionBackup.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerTransactionBackup.ts @@ -21,6 +21,9 @@ type UseOdometerTransactionBackupParams = { /** Caller-owned ref. Set `.current = true` to bypass cleanup entirely (used when the parent has already manually handled the backup, e.g. tab-switch flows). */ backupHandledManuallyRef: React.RefObject; + + /** Caller-owned ref. Set `.current = true` by the blob-failure recovery: the transaction was re-hydrated/cleared, so the unmount restore must be skipped (it would revert that). Separate from `backupHandledManuallyRef` so the recovery doesn't disable the discard-changes guard. */ + recoveryHandledBackupRef: React.RefObject; }; function useOdometerTransactionBackup({ @@ -30,6 +33,7 @@ function useOdometerTransactionBackup({ transactionID, didSaveEditingConfirmationRef, backupHandledManuallyRef, + recoveryHandledBackupRef, }: UseOdometerTransactionBackupParams): void { useEffect(() => { if (!isEditingConfirmation) { @@ -39,7 +43,7 @@ function useOdometerTransactionBackup({ return () => { // eslint-disable-next-line react-hooks/exhaustive-deps -- ref reads are stable; we intentionally read the latest `.current` at unmount to decide between bypass / drop / restore - if (backupHandledManuallyRef.current) { + if (backupHandledManuallyRef.current || recoveryHandledBackupRef.current) { return; } // eslint-disable-next-line react-hooks/exhaustive-deps -- ref reads are stable; we intentionally read the latest `.current` at unmount diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 88e2abae907b..bfed1f1679c3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -94,6 +94,9 @@ function IOURequestStepDistanceOdometer({ const didSaveEditingConfirmationRef = useRef(false); const shouldBypassDiscardConfirmationRef = useRef(false); const backupHandledManually = useRef(false); + // Blob-failure recovery sets this so useOdometerTransactionBackup skips unmount restore. Separate from + // backupHandledManually so discard-changes prompts still work after reload + const recoveryHandledBackupRef = useRef(false); const userHasUnsavedTypingRef = useRef(false); const isArchived = useReportIsArchived(report?.reportID); @@ -157,7 +160,7 @@ function IOURequestStepDistanceOdometer({ if (shouldResetLocalState) { resetOdometerLocalStateRef.current(); } - backupHandledManually.current = true; + recoveryHandledBackupRef.current = true; }); const [odometerDraft] = useOnyx(ONYXKEYS.ODOMETER_DRAFT); @@ -195,6 +198,7 @@ function IOURequestStepDistanceOdometer({ transactionID, didSaveEditingConfirmationRef, backupHandledManuallyRef: backupHandledManually, + recoveryHandledBackupRef, }); const navigateToNextStep = useOdometerNavigation({ @@ -502,6 +506,8 @@ function IOURequestStepDistanceOdometer({ setMoneyRequestOdometerReading(transactionID, null, null, isTransactionDraft); removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, isTransactionDraft, true); removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.END, isTransactionDraft, true); + // Clear typing guard so draft re-hydration can resync readings into the inputs on return. + userHasUnsavedTypingRef.current = false; resetOdometerLocalState(); setFormError(''); }; diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx index dbab40734cc2..49846d6db243 100644 --- a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import {act, render} from '@testing-library/react-native'; +import {act, fireEvent, render, screen} from '@testing-library/react-native'; import React from 'react'; import Onyx from 'react-native-onyx'; import {CurrentUserPersonalDetailsProvider} from '@components/CurrentUserPersonalDetailsProvider'; @@ -284,7 +284,7 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan // Seeds the edit-from-confirmation flow with an active save-for-later readings draft and renders, returning the // captured tab guard. `startImage` optionally seeds an image already present at mount (for swap/remove) - async function setupAndRender(startImage?: FileObject): Promise<{getHasUnsavedChanges: () => boolean; transaction: Transaction}> { + async function setupAndRender(startImage?: FileObject): Promise<{getHasUnsavedChanges: () => boolean; onDiscard: () => Promise; transaction: Transaction}> { const transaction = createOdometerTransaction(); if (startImage) { transaction.comment = {...transaction.comment, odometerStartImage: startImage}; @@ -306,9 +306,51 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan renderWithCapturedGuard(register); await waitForBatchedUpdatesWithAct(); - return {getHasUnsavedChanges: () => capturedGuard?.getHasUnsavedChanges() ?? false, transaction}; + return { + getHasUnsavedChanges: () => capturedGuard?.getHasUnsavedChanges() ?? false, + onDiscard: async () => { + await capturedGuard?.onDiscard(); + }, + transaction, + }; } + // After "Discard" on a tab switch, handleTabSwitchDiscard wipes the transaction and lowers the typing guard. When + // the save-for-later draft re-hydrates the readings back into the transaction on return, the inputs must repopulate. + // Regression: the typing guard used to stay raised, blocking the resync, so readings stayed empty while images showed. + it('repopulates the readings after discard once the draft re-hydrates', async () => { + const {onDiscard} = await setupAndRender(); + const getStartInput = () => { + const input = screen.getAllByLabelText('distance.odometer.startReading').find((element) => 'value' in element.props); + if (!input) { + throw new Error('Start reading input not found'); + } + return input; + }; + + // The save-for-later draft hydrated the readings into the inputs + expect(getStartInput().props.value).toBe(String(ODOMETER_START)); + + // User edits the start reading -> raises the typing guard + fireEvent.changeText(getStartInput(), '999'); + await waitForBatchedUpdatesWithAct(); + + // Discard the tab switch: wipes the transaction readings/images, resets local state, lowers the typing guard + await act(async () => { + await onDiscard(); + }); + await waitForBatchedUpdatesWithAct(); + + // The draft re-hydrates the readings back into the transaction when returning to the odometer tab + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {comment: {odometerStart: ODOMETER_START, odometerEnd: ODOMETER_END}}); + }); + await waitForBatchedUpdatesWithAct(); + + // Inputs repopulate from the re-hydrated comment - they'd stay '' if the typing guard hadn't been lowered on discard + expect(getStartInput().props.value).toBe(String(ODOMETER_START)); + }); + it('flags an added image as unsaved (the false-negative being fixed)', async () => { const {getHasUnsavedChanges, transaction} = await setupAndRender(); diff --git a/tests/unit/hooks/useOdometerTransactionBackup.test.ts b/tests/unit/hooks/useOdometerTransactionBackup.test.ts index 21d81e3b3809..da31d4abfcb0 100644 --- a/tests/unit/hooks/useOdometerTransactionBackup.test.ts +++ b/tests/unit/hooks/useOdometerTransactionBackup.test.ts @@ -38,6 +38,7 @@ const renderBackupHook = (overrides: Partial = {}) => { transactionID: TRANSACTION_ID, didSaveEditingConfirmationRef: {current: false}, backupHandledManuallyRef: {current: false}, + recoveryHandledBackupRef: {current: false}, ...overrides, }; return {original, ...renderHook(() => useOdometerTransactionBackup(params)), params}; @@ -84,6 +85,30 @@ describe('useOdometerTransactionBackup', () => { expect(backupAfterUnmount).toBeUndefined(); }); + // The blob-failure recovery re-hydrates/clears the transaction and sets recoveryHandledBackupRef. The unmount + // restore must then be skipped so it doesn't revert the recovery's work back to the pre-edit original. + it('skips the unmount restore when the recovery already settled the transaction', async () => { + const original = buildOdometerTransaction(); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, original); + await waitForBatchedUpdates(); + + const recoveryHandledBackupRef = {current: false}; + const {unmount} = renderBackupHook({transaction: original, recoveryHandledBackupRef}); + await waitForBatchedUpdates(); + + // The recovery re-hydrated the transaction to a new state and flagged that it handled the backup + const recovered = {...original, comment: {odometerStart: 100, odometerEnd: 999}, merchant: '899 mi', amount: 99999}; + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, recovered); + await waitForBatchedUpdates(); + recoveryHandledBackupRef.current = true; + + unmount(); + await waitForBatchedUpdates(); + + // The re-hydrated state survives - the restore did NOT run and revert it to the original + expect(await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`)).toEqual(recovered); + }); + it('does not back up or restore when not editing from confirmation', async () => { const original = buildOdometerTransaction(); await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${TRANSACTION_ID}`, original); diff --git a/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts new file mode 100644 index 000000000000..6bf5018458a7 --- /dev/null +++ b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts @@ -0,0 +1,126 @@ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import type useRestartOnOdometerImagesFailureType from '@hooks/useRestartOnOdometerImagesFailure'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Transaction} from '@src/types/onyx'; +import createRandomTransaction from '../../utils/collections/transaction'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +// The hook has a native variant that hardcodes hasVerifiedBlobs:true (no verification). Jest's default platform is +// native, so force the WEB implementation (index.ts) - that's the only platform where the blob-verification path exists. +const useRestartOnOdometerImagesFailure = jest.requireActual<{default: typeof useRestartOnOdometerImagesFailureType}>('@hooks/useRestartOnOdometerImagesFailure/index.ts').default; + +// Drive blob-accessibility verification deterministically: a "dead" blob (post-reload) takes the failure path. +const mockCheckIfLocalFileIsAccessible = jest.fn void, () => void]>(); +jest.mock('@libs/actions/IOU/Receipt', () => ({ + checkIfLocalFileIsAccessible: (filename: string | undefined, path: string | undefined, type: string | undefined, onSuccess: () => void, onFailure: () => void) => + mockCheckIfLocalFileIsAccessible(filename, path, type, onSuccess, onFailure), +})); + +// Spy the failure-branch side effects (rehydrate / clear) and break the heavy transitive import chain. +const mockHydrate = jest.fn(); +const mockClear = jest.fn(); +jest.mock('@libs/actions/OdometerTransactionUtils', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockClear(...args), + hydrateOdometerDraftIntoTransaction: (...args: unknown[]) => mockHydrate(...args), +})); + +const mockNavigateToStartMoneyRequestStep = jest.fn(); +jest.mock('@libs/IOUUtils', () => ({ + navigateToStartMoneyRequestStep: (...args: unknown[]) => mockNavigateToStartMoneyRequestStep(...args), +})); + +// Keep getOdometerImageUri real (mirrors the real impl) but break the FileUtils -> API transitive chain. +jest.mock('@libs/OdometerImageUtils', () => ({ + __esModule: true, + default: jest.fn(), + getOdometerImageUri: (image: {uri?: string} | string | null | undefined) => (typeof image === 'string' ? image : (image?.uri ?? '')), +})); + +const REPORT_ID = 'report1'; +const TRANSACTION_ID = 'txn1'; + +function buildTransactionWithDeadBlob(): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + odometerStart: 100, + odometerEnd: 300, + // A blob: URL is what a reloaded session leaves behind; checkIfLocalFileIsAccessible will fail on it. + odometerStartImage: {uri: 'blob:http://localhost/dead', name: 'start.png', type: 'image/png', size: 10}, + }, + }; +} + +describe('useRestartOnOdometerImagesFailure', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + // A dead blob fails accessibility verification (the post-reload state). + mockCheckIfLocalFileIsAccessible.mockImplementation((filename, path, type, onSuccess, onFailure) => onFailure()); + await waitForBatchedUpdates(); + }); + + // When a save-for-later draft exists the recovery re-mints from base64, marks verification passed (so the + // baseline snapshot can initialize), and signals backup-handled with shouldResetLocalState:false. The caller + // routes that signal to a recovery-only ref (not backupHandledManually), so the discard guard stays active. + it('rehydrates, marks verified, and signals backup-handled (no local reset) when a draft exists', async () => { + const transaction = buildTransactionWithDeadBlob(); + const onBackupHandled = jest.fn(); + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, {odometerStartReading: 100, odometerEndReading: 300, odometerStartImage: 'data:image/png;base64,xxx'}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useRestartOnOdometerImagesFailure(transaction, REPORT_ID, CONST.IOU.TYPE.SUBMIT, undefined, onBackupHandled)); + await waitForBatchedUpdates(); + + expect(result.current.hasVerifiedBlobs).toBe(true); + expect(mockHydrate).toHaveBeenCalled(); + expect(onBackupHandled).toHaveBeenCalledWith({shouldResetLocalState: false}); + expect(mockClear).not.toHaveBeenCalled(); + expect(mockNavigateToStartMoneyRequestStep).toHaveBeenCalled(); + }); + + // No draft = truly unrecoverable images: the backup-handled signal + clear + restart are still required. + it('clears and signals backup-handled when no draft exists', async () => { + const transaction = buildTransactionWithDeadBlob(); + const onBackupHandled = jest.fn(); + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, null); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + renderHook(() => useRestartOnOdometerImagesFailure(transaction, REPORT_ID, CONST.IOU.TYPE.SUBMIT, undefined, onBackupHandled)); + await waitForBatchedUpdates(); + + expect(onBackupHandled).toHaveBeenCalledWith({shouldResetLocalState: true}); + expect(mockClear).toHaveBeenCalled(); + expect(mockHydrate).not.toHaveBeenCalled(); + expect(mockNavigateToStartMoneyRequestStep).toHaveBeenCalled(); + }); + + // Sanity: with no blob URLs to verify there's nothing to recover - verification passes immediately. + it('reports verified when the transaction has no blob URLs', async () => { + const transaction = createRandomTransaction(1); + transaction.transactionID = TRANSACTION_ID; + transaction.comment = {odometerStart: 100, odometerEnd: 300}; + await Onyx.set(ONYXKEYS.ODOMETER_DRAFT, null); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await waitForBatchedUpdates(); + + const {result} = renderHook(() => useRestartOnOdometerImagesFailure(transaction, REPORT_ID, CONST.IOU.TYPE.SUBMIT, undefined, jest.fn())); + await waitForBatchedUpdates(); + + expect(result.current.hasVerifiedBlobs).toBe(true); + expect(mockNavigateToStartMoneyRequestStep).not.toHaveBeenCalled(); + }); +}); From 04a18534a265fd73b55516d5bee9ea8b4798bbb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 23 Jun 2026 17:03:44 +0200 Subject: [PATCH 32/42] chore: prettier --- .../IOURequestStepDistance/hooks/useOdometerReadingsState.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index 011d1b1b223e..5f77d30b6f92 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -2,11 +2,11 @@ import {useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; +import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; import CONST from '@src/CONST'; import type {OdometerDraft, Transaction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; -import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; -import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; type UseOdometerReadingsStateParams = { /** The transaction whose odometer values seed and re-sync the local form state. */ From 4def23dd86b10ac2e6bfc4bf1bb3e5a2a9590f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Tue, 23 Jun 2026 17:43:08 +0200 Subject: [PATCH 33/42] chore: remove unused eslint-disable directive in useOdometerReadingsState --- .../IOURequestStepDistance/hooks/useOdometerReadingsState.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index 5f77d30b6f92..003a082551c7 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -203,7 +203,6 @@ function useOdometerReadingsState({ // Sync the local readings up from the transaction (but never clobber in-progress typing) if (shouldInitializeOdometerFromTransaction(resyncState, isExternalResync) && (startValue || endValue)) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncing local form state from the Onyx transaction (an external source) when the user navigates back to the page setStartReading(startValue); setEndReading(endValue); startReadingRef.current = startValue; From 9f3f4750ec809b16bb27625a88ddf85d060f02d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 24 Jun 2026 13:19:30 +0200 Subject: [PATCH 34/42] fix(odometer): detect same-name/same-size image swaps on native Native odometer image objects have no lastModified, so getOdometerImageIdentity collapsed to name|size and dropped the only unique field (uri). Replacing a photo with a different file sharing filename + byte size compared equal, so the discard guard stayed silent and a tab switch silently wiped the replacement. Fall back to the durable file:// uri when lastModified is absent. Native uris are unique per selection (rand64 suffix) and stable across the draft round-trip (no blob re-mint), so the swap is detected without false positives. Web File objects always carry lastModified, so the re-mint-invariant identity is unchanged there. --- src/libs/OdometerImageUtils.ts | 9 ++++++--- tests/actions/OdometerTransactionUtilsTest.ts | 20 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index 0a8813e8aa12..803884e830e1 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -11,9 +11,11 @@ function getOdometerImageName(image: FileObject | string | null | undefined): st /** * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. - * `uri` changes on every re-mint, but `name|size|lastModified` is preserved across the draft round-trip, so it + * On web, `uri` changes on every re-mint, but `name|size|lastModified` is preserved across the draft round-trip, so it * stays stable on resume/reload while still changing for a real swap (`lastModified` disambiguates a different - * file that happens to share name + size). Native images are stable `file://` strings, so we use them directly + * file that happens to share name + size). Native image objects have no `lastModified`, but their durable `file://` + * uri is unique per selection (rand64 suffix) and stable across the draft round-trip (no blob re-mint on native), so + * we fall back to it there to keep a same-name/same-size swap detectable. */ function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { if (!image) { @@ -22,7 +24,8 @@ function getOdometerImageIdentity(image: FileObject | string | null | undefined) if (typeof image === 'string') { return image; } - return `${image.name ?? ''}|${image.size ?? ''}|${image.lastModified ?? ''}`; + const base = `${image.name ?? ''}|${image.size ?? ''}`; + return image.lastModified !== undefined ? `${base}|${image.lastModified}` : `${base}|${image.uri ?? ''}`; } function getOdometerImageType(image: FileObject | string | null | undefined): string | undefined { diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 7b77708077cb..731b9727ba7c 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -421,11 +421,12 @@ describe('actions/OdometerTransactionUtils', () => { expect(getOdometerImageIdentity(null)).toBe(''); }); - // A non-user blob re-mint (base64 -> blob on resume/reload) keeps name + size and only changes the uri, so - // the identity must stay equal - this is what lets the discard guard stay silent on a re-mint - it('is invariant under a blob re-mint (same name + size, new uri)', () => { - const original = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234}; - const reminted = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + // A non-user blob re-mint (base64 -> blob on resume/reload) keeps name + size + lastModified (web File objects + // always carry lastModified, and it is restored from the draft on re-mint) and only changes the uri, so the + // identity must stay equal - this is what lets the discard guard stay silent on a re-mint + it('is invariant under a blob re-mint (same name + size + lastModified, new uri)', () => { + const original = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + const reminted = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; expect(getOdometerImageIdentity(reminted)).toBe(getOdometerImageIdentity(original)); }); @@ -456,5 +457,14 @@ describe('actions/OdometerTransactionUtils', () => { it('uses the uri string directly for native images', () => { expect(getOdometerImageIdentity('file:///path/to/a.jpg')).toBe('file:///path/to/a.jpg'); }); + + // Native image objects have no lastModified, so name|size alone would collide for two different files that + // happen to share both. The durable file:// uri (unique per selection) disambiguates them so the discard guard + // still fires on the swap. + it('changes when a native (no lastModified) swap shares name + size but has a different uri', () => { + const a = {uri: 'file:///path/to/a_111.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const b = {uri: 'file:///path/to/a_222.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + expect(getOdometerImageIdentity(b)).not.toBe(getOdometerImageIdentity(a)); + }); }); }); From eae899a6d17d27e892f2ee8d52b8cbe2d1a6535e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 24 Jun 2026 20:52:19 +0200 Subject: [PATCH 35/42] fix(odometer): make image-identity uri fallback native-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getOdometerImageIdentity feeds the discard-changes guard. When lastModified was missing it appended the uri on every platform, but a web image uri is a blob: that re-mints on resume/reload — so the identity changed when nothing did and the guard fired a false "discard changes?" prompt. Fall back to the uri only on native, where the file:// uri is durable and disambiguates a same-name/size swap; on web use name|size alone. Add a web-branch unit test, and seed lastModified in the backup UI fixtures so the re-mint case models a real web File object (always carries lastModified). --- src/libs/OdometerImageUtils.ts | 20 +++++++++++++------ tests/actions/OdometerTransactionUtilsTest.ts | 19 ++++++++++++++++++ ...URequestStepDistanceOdometerBackupTest.tsx | 12 +++++------ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index 803884e830e1..7bef5810ccf5 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -1,5 +1,7 @@ +import CONST from '@src/CONST'; import type {FileObject} from '@src/types/utils/Attachment'; import {getMimeTypeFromUri} from './fileDownload/FileUtils'; +import getPlatform from './getPlatform'; function getOdometerImageUri(image: FileObject | string | null | undefined): string { return typeof image === 'string' ? image : (image?.uri ?? ''); @@ -11,11 +13,12 @@ function getOdometerImageName(image: FileObject | string | null | undefined): st /** * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. - * On web, `uri` changes on every re-mint, but `name|size|lastModified` is preserved across the draft round-trip, so it - * stays stable on resume/reload while still changing for a real swap (`lastModified` disambiguates a different - * file that happens to share name + size). Native image objects have no `lastModified`, but their durable `file://` - * uri is unique per selection (rand64 suffix) and stable across the draft round-trip (no blob re-mint on native), so - * we fall back to it there to keep a same-name/same-size swap detectable. + * `name|size|lastModified` is preserved across the draft round-trip, so it stays stable on resume/reload while still + * changing for a real swap (`lastModified` disambiguates a different file that happens to share name + size). + * When `lastModified` is missing we fall back to the uri ONLY on native, where the `file://` uri is durable, unique per + * selection (rand64 suffix), and stable across the round-trip (no blob re-mint) - so a same-name/same-size swap stays + * detectable. On web the uri is a volatile `blob:` that re-mints on every resume/reload, so including it would fire a + * false discard prompt; there we use `name|size` alone. */ function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { if (!image) { @@ -25,7 +28,12 @@ function getOdometerImageIdentity(image: FileObject | string | null | undefined) return image; } const base = `${image.name ?? ''}|${image.size ?? ''}`; - return image.lastModified !== undefined ? `${base}|${image.lastModified}` : `${base}|${image.uri ?? ''}`; + if (image.lastModified !== undefined) { + return `${base}|${image.lastModified}`; + } + // No lastModified: only native's durable file:// uri is a safe disambiguator. On web the uri is a volatile blob: + // that re-mints on resume/reload, so name|size alone keeps the identity stable and avoids a false discard prompt. + return getPlatform() === CONST.PLATFORM.WEB ? base : `${base}|${image.uri ?? ''}`; } function getOdometerImageType(image: FileObject | string | null | undefined): string | undefined { diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 731b9727ba7c..384228f142d8 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -11,6 +11,7 @@ import { } from '@libs/actions/OdometerTransactionUtils'; import type {OdometerUnsavedChangesState} from '@libs/actions/OdometerTransactionUtils'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; +import getPlatform from '@libs/getPlatform'; import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -107,6 +108,9 @@ jest.mock('@libs/PolicyUtils', () => ({ isPolicyOwner: jest.fn().mockImplementation((policy?: OnyxEntry, currentUserAccountID?: number) => !!currentUserAccountID && policy?.ownerAccountID === currentUserAccountID), })); +jest.mock('@libs/getPlatform', () => jest.fn()); +const mockGetPlatform = jest.mocked(getPlatform); + const RORY_EMAIL = 'rory@expensifail.com'; const RORY_ACCOUNT_ID = 3; @@ -416,6 +420,12 @@ describe('actions/OdometerTransactionUtils', () => { }); describe('getOdometerImageIdentity (re-mint-invariant)', () => { + // Unmocked, getPlatform resolves to native (jest-expo defaultPlatform 'ios'); pin it so the native fallback + // (uri included) is deterministic. Tests that need the web fallback flip it to CONST.PLATFORM.WEB themselves. + beforeEach(() => { + mockGetPlatform.mockReturnValue(CONST.PLATFORM.IOS); + }); + it('is empty for a missing image', () => { expect(getOdometerImageIdentity(undefined)).toBe(''); expect(getOdometerImageIdentity(null)).toBe(''); @@ -466,5 +476,14 @@ describe('actions/OdometerTransactionUtils', () => { const b = {uri: 'file:///path/to/a_222.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}; expect(getOdometerImageIdentity(b)).not.toBe(getOdometerImageIdentity(a)); }); + + // On web the uri is a volatile blob: that re-mints across reloads. With no lastModified to anchor identity, a + // uri-only change must NOT register as a swap (name|size alone) - otherwise the discard guard fires on resume. + it('is invariant under a web blob re-mint with no lastModified (uri-only change)', () => { + mockGetPlatform.mockReturnValue(CONST.PLATFORM.WEB); + const original = {uri: 'blob:abc', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + const reminted = {uri: 'blob:xyz', name: 'a.jpg', type: 'image/jpeg', size: 1234}; + expect(getOdometerImageIdentity(reminted)).toBe(getOdometerImageIdentity(original)); + }); }); }); diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx index 49846d6db243..a787d015b443 100644 --- a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -252,8 +252,8 @@ describe('IOURequestStepDistanceOdometer - edit-from-confirmation backup is rest // moved ahead of the draft. Unlike the unit tests, these run the whole path (resync effect + image baseline + // getOdometerHasUnsavedChanges), not just the pure function. describe('IOURequestStepDistanceOdometer - discard guard detects user image changes with an active save-for-later draft', () => { - const START_IMAGE_A: FileObject = {uri: 'a.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}; - const START_IMAGE_B: FileObject = {uri: 'b.jpg', name: 'b.jpg', type: 'image/jpeg', size: 5678}; + const START_IMAGE_A: FileObject = {uri: 'a.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}; + const START_IMAGE_B: FileObject = {uri: 'b.jpg', name: 'b.jpg', type: 'image/jpeg', size: 5678, lastModified: 2000}; beforeAll(() => { Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); @@ -400,9 +400,9 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan expect(getHasUnsavedChanges()).toBe(false); }); - // A NON-user image URI change (blob re-mint on reload, external save) keeps the file name + size and only changes - // the uri, so its re-mint-invariant identity (getOdometerImageIdentity = name|size) is unchanged and the guard - // stays silent - no "user edited" tracking needed + // A NON-user image URI change (blob re-mint on reload, external save) keeps name + size + lastModified and only + // changes the uri, so its re-mint-invariant identity (getOdometerImageIdentity = name|size|lastModified) is + // unchanged and the guard stays silent - no "user edited" tracking needed it('stays silent for a non-user image URI change (e.g. blob re-mint) because the identity is invariant', async () => { const {getHasUnsavedChanges} = await setupAndRender(START_IMAGE_A); @@ -411,7 +411,7 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan // Change the image URI WITHOUT going through the user-edit actions -> no marker is set await act(async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { - comment: {odometerStartImage: {uri: 'a-reminted.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234}}, + comment: {odometerStartImage: {uri: 'a-reminted.jpg', name: 'a.jpg', type: 'image/jpeg', size: 1234, lastModified: 1000}}, }); }); await waitForBatchedUpdatesWithAct(); From b64dcc998d3595ab7766e3022126fed6e4d900cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 24 Jun 2026 21:08:02 +0200 Subject: [PATCH 36/42] chore(odometer): reword comment to satisfy cspell --- src/libs/OdometerImageUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts index 7bef5810ccf5..a4500588d4a2 100644 --- a/src/libs/OdometerImageUtils.ts +++ b/src/libs/OdometerImageUtils.ts @@ -31,7 +31,7 @@ function getOdometerImageIdentity(image: FileObject | string | null | undefined) if (image.lastModified !== undefined) { return `${base}|${image.lastModified}`; } - // No lastModified: only native's durable file:// uri is a safe disambiguator. On web the uri is a volatile blob: + // No lastModified: only native's durable file:// uri is a safe way to tell images apart. On web the uri is a volatile blob: // that re-mints on resume/reload, so name|size alone keeps the identity stable and avoids a false discard prompt. return getPlatform() === CONST.PLATFORM.WEB ? base : `${base}|${image.uri ?? ''}`; } From 9d90e25d3346b1a65702da8083df9abf1c4ad353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Wed, 24 Jun 2026 22:57:30 +0200 Subject: [PATCH 37/42] fix(odometer): don't treat image-only changes as reading resyncs isExternalOdometerResync slides the readings discard-baseline, yet it also fired on image-only changes. In the create flow, adding then reverting an odometer image re-baselined still-unsent readings, so the "Discard changes?" prompt was silently dropped on tab switch / back. Make the predicate readings-only (drop the unused image fields and deps). Image edits are still tracked separately against the never-slid image baseline. Add hook- and UI-level regression tests. --- .../hooks/useOdometerReadingsState.ts | 18 +-- .../IOURequestStepDistance/odometerResync.ts | 18 +-- ...URequestStepDistanceOdometerBackupTest.tsx | 152 ++++++++++++++++++ .../hooks/useOdometerReadingsState.test.ts | 21 +++ tests/unit/odometerResync.test.ts | 12 +- 5 files changed, 184 insertions(+), 37 deletions(-) diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index 003a082551c7..9588f4906cfd 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -1,7 +1,6 @@ import {useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; -import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; import CONST from '@src/CONST'; @@ -188,10 +187,6 @@ function useOdometerReadingsState({ transactionEndValue: endValue, localStartValue: startReadingRef.current, localEndValue: endReadingRef.current, - transactionStartImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerStartImage), - transactionEndImageUri: getOdometerImageIdentity(currentTransaction?.comment?.odometerEndImage), - baselineStartImageUri: getOdometerImageIdentity(initialStartImageRef.current), - baselineEndImageUri: getOdometerImageIdentity(initialEndImageRef.current), hasTransactionData: (currentStart !== null && currentStart !== undefined) || (currentEnd !== null && currentEnd !== undefined), hasLocalState: !!(startReadingRef.current || endReadingRef.current), hasInitialized: hasInitializedRefs.current, @@ -209,20 +204,13 @@ function useOdometerReadingsState({ endReadingRef.current = endValue; } - // Slide the readings baseline on a non-user change (draft hydration, external save) so leaving doesn't flag it as unsaved. - // Images aren't slid: their re-mint-invariant diff already ignores re-mints, and sliding would absorb a genuine swap. + // Slide the readings baseline on an external (non-user) reading change (e.g. draft hydration) so leaving + // doesn't flag it. Images aren't slid - they use a separate, never-slid, re-mint-invariant baseline. if (isExternalResync) { initialStartReadingRef.current = startValue; initialEndReadingRef.current = endValue; } - }, [ - currentTransaction?.comment?.odometerStart, - currentTransaction?.comment?.odometerEnd, - currentTransaction?.comment?.odometerStartImage, - currentTransaction?.comment?.odometerEndImage, - isEditing, - userHasUnsavedTypingRef, - ]); + }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing, userHasUnsavedTypingRef]); return { startReading, diff --git a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts index 4de5385efcc4..d517905b222a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts @@ -16,12 +16,6 @@ type OdometerResyncState = { localStartValue: string; localEndValue: string; - /** Resolved image URIs from the transaction and from the on-mount baseline */ - transactionStartImageUri: string; - transactionEndImageUri: string; - baselineStartImageUri: string; - baselineEndImageUri: string; - /** Whether the transaction carries any odometer reading */ hasTransactionData: boolean; @@ -39,19 +33,15 @@ type OdometerResyncState = { }; /** - * An "external resync": the transaction changed elsewhere (e.g. an edit saved from the confirmation step) while - * nothing is being typed here, so it becomes the new baseline. The typing guard avoids clobbering in-progress keystrokes. + * Whether the transaction *readings* changed externally (not from typing here), making them the new readings baseline. + * Readings-only by design: this slides the readings baseline, so counting image changes would re-baseline still-unsent + * readings and drop the discard prompt. Images are tracked separately against a never-slid baseline. */ function isExternalOdometerResync(state: OdometerResyncState): boolean { if (!state.hasTransactionData || !state.hasInitialized || state.isUserTyping) { return false; } - return ( - state.transactionStartValue !== state.localStartValue || - state.transactionEndValue !== state.localEndValue || - state.transactionStartImageUri !== state.baselineStartImageUri || - state.transactionEndImageUri !== state.baselineEndImageUri - ); + return state.transactionStartValue !== state.localStartValue || state.transactionEndValue !== state.localEndValue; } /** diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx index a787d015b443..76af73dcd0b9 100644 --- a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -81,6 +81,19 @@ jest.mock('@hooks/useScreenWrapperTransitionStatus', () => ({ default: () => ({didScreenTransitionEnd: true}), })); +// Stub the navigation hook so pressing "Next" in the create flow commits readings + lowers the typing guard without +// triggering real navigation - the create-flow tests below only assert the discard-guard state, not where Next routes. +jest.mock('@pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerNavigation', () => ({ + __esModule: true, + default: () => jest.fn(), +})); + +// Next opens a telemetry span; stub it so the real Sentry span machinery doesn't run in the test. +jest.mock('@libs/telemetry/activeSpans', () => ({ + ...jest.requireActual('@libs/telemetry/activeSpans'), + startSpan: jest.fn(), +})); + jest.mock('@libs/Navigation/navigationRef', () => ({ getCurrentRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance_Odometer', params: {}})), getState: jest.fn(() => ({})), @@ -419,3 +432,142 @@ describe('IOURequestStepDistanceOdometer - discard guard detects user image chan expect(getHasUnsavedChanges()).toBe(false); }); }); + +// Create-flow regression (codex P2): an image add + revert must not corrupt the readings discard-baseline. +// Repro: type readings -> Next (commits them to the transaction; the readings baseline stays empty) -> add image -> +// remove image. Pre-fix, adding the image flagged an external resync and slid the readings baseline to the committed +// values; reverting the image then left both the reading diff and the image diff clean, so getHasUnsavedChanges() +// wrongly returned false and the discard prompt was silently lost while unsent readings remained. +describe('IOURequestStepDistanceOdometer - an image add/revert must not drop the readings discard prompt (create flow)', () => { + const START_IMAGE: FileObject = {uri: 'odo.jpg', name: 'odo.jpg', type: 'image/jpeg', size: 4321, lastModified: 3000}; + + beforeAll(() => { + Onyx.init({keys: ONYXKEYS, evictableKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + }); + + // A create-flow route (name = DISTANCE_CREATE) so `isCreatingNewRequest` is true and the Next button keeps the + // discard guard active. The edit-from-confirmation route used above sets `didSaveEditingConfirmationRef` on Next, + // which disables the guard - unsuitable for this repro. + function createDistanceCreateRoute(): PlatformStackScreenProps['route'] { + // The DISTANCE_CREATE route types `action`/`backTo` as `never` (unused for navigation but read at runtime here), so the params object can't be built without one assertion. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- see comment above + const params = { + action: CONST.IOU.ACTION.CREATE, + iouType: CONST.IOU.TYPE.SUBMIT, + reportID: REPORT_ID, + transactionID: TRANSACTION_ID, + } as unknown as MoneyRequestNavigatorParamList[typeof SCREENS.MONEY_REQUEST.DISTANCE_CREATE]; + return { + key: 'Money_Request_Distance_Create-test', + name: SCREENS.MONEY_REQUEST.DISTANCE_CREATE, + params, + }; + } + + // An odometer transaction with no readings yet, so the on-mount readings baseline snapshots as empty. + function createEmptyOdometerTransaction(): Transaction { + const transaction = createRandomTransaction(1); + return { + ...transaction, + transactionID: TRANSACTION_ID, + reportID: REPORT_ID, + iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, + comment: { + ...transaction.comment, + odometerStart: undefined, + odometerEnd: undefined, + customUnit: { + customUnitID: 'test-unit-id', + customUnitRateID: 'test-rate-id', + name: 'Distance', + distanceUnit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, + }, + }, + }; + } + + async function setupCreateFlow(): Promise<{getHasUnsavedChanges: () => boolean; transaction: Transaction}> { + const transaction = createEmptyOdometerTransaction(); + let capturedGuard: TabSwitchGuard | undefined; + const register: RegisterTabSwitchGuard = (guard) => { + capturedGuard = guard; + return () => {}; + }; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${REPORT_ID}`, createTestReport()); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, transaction); + await Onyx.merge(ONYXKEYS.IS_LOADING_APP, false); + }); + + render( + + + + + + + , + ); + await waitForBatchedUpdatesWithAct(); + + return { + getHasUnsavedChanges: () => capturedGuard?.getHasUnsavedChanges() ?? false, + transaction, + }; + } + + const findInput = (label: string) => { + const input = screen.getAllByLabelText(label).find((element) => 'value' in element.props); + if (!input) { + throw new Error(`Input not found: ${label}`); + } + return input; + }; + + it('keeps the discard prompt after readings are committed, an image is added, then reverted', async () => { + const {getHasUnsavedChanges, transaction} = await setupCreateFlow(); + + // Nothing entered yet -> clean baseline, no prompt. + expect(getHasUnsavedChanges()).toBe(false); + + // Enter readings, then commit them with Next (writes them to the transaction and lowers the typing guard). + fireEvent.changeText(findInput('distance.odometer.startReading'), '100'); + fireEvent.changeText(findInput('distance.odometer.endReading'), '250'); + await waitForBatchedUpdatesWithAct(); + + fireEvent.press(screen.getByTestId('next-save-button')); + await waitForBatchedUpdatesWithAct(); + + // The committed-but-unsent readings differ from the empty baseline -> the prompt fires. + expect(getHasUnsavedChanges()).toBe(true); + + // Add an odometer image - still unsaved (the image now differs from its baseline too). + await act(async () => { + setMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, START_IMAGE, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + expect(getHasUnsavedChanges()).toBe(true); + + // Revert the image. The readings are still unsent, so the prompt MUST remain. Pre-fix the earlier image add slid + // the readings baseline, so this returned false and the discard prompt was silently lost. + await act(async () => { + removeMoneyRequestOdometerImage(transaction, CONST.IOU.ODOMETER_IMAGE_TYPE.START, true, false); + await waitForBatchedUpdates(); + }); + await waitForBatchedUpdatesWithAct(); + expect(getHasUnsavedChanges()).toBe(true); + }); +}); diff --git a/tests/unit/hooks/useOdometerReadingsState.test.ts b/tests/unit/hooks/useOdometerReadingsState.test.ts index a72117ab996b..d5361f6c39a3 100644 --- a/tests/unit/hooks/useOdometerReadingsState.test.ts +++ b/tests/unit/hooks/useOdometerReadingsState.test.ts @@ -90,6 +90,27 @@ describe('useOdometerReadingsState', () => { expect(result.current.initialStartReadingRef.current).toBe('100'); }); + // Regression: an image-only external change must NOT slide the readings baseline. Pre-fix, isExternalOdometerResync + // treated an image diff as an external resync and slid initialStart/EndReadingRef to the transaction readings, which + // masked still-unsent readings once the image was reverted (the discard prompt would then never fire). + it('does not slide the readings baseline when only an image changes externally', () => { + const {result, rerender} = renderHook((params: Params) => useOdometerReadingsState(params), {initialProps: baseParams}); + + // Simulate the create-flow state: committed transaction readings but an intentionally-empty readings baseline + // (the post-type+Next state the hook can't reach via typing in isolation). + act(() => { + result.current.initialStartReadingRef.current = ''; + result.current.initialEndReadingRef.current = ''; + }); + + // An image is added externally; the readings themselves do not change. + rerender({...baseParams, currentTransaction: buildOdometerTransaction({odometerStartImage: {uri: 'new.jpg'}})}); + + // The readings baseline must stay empty - pre-fix it slid to '100'/'250' and hid the unsent readings. + expect(result.current.initialStartReadingRef.current).toBe(''); + expect(result.current.initialEndReadingRef.current).toBe(''); + }); + it('captures initial baseline refs once blobs are verified and no draft is pending', () => { const {result} = renderHook(() => useOdometerReadingsState(baseParams)); diff --git a/tests/unit/odometerResync.test.ts b/tests/unit/odometerResync.test.ts index 285cd94ecff0..f2de8b4e96d5 100644 --- a/tests/unit/odometerResync.test.ts +++ b/tests/unit/odometerResync.test.ts @@ -1,16 +1,12 @@ import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; -// A steady state: initialized, transaction and local readings/images all match, nothing being typed. +// A steady state: initialized, transaction and local readings match, nothing being typed. const STEADY_STATE: OdometerResyncState = { transactionStartValue: '100', transactionEndValue: '200', localStartValue: '100', localEndValue: '200', - transactionStartImageUri: 'start.jpg', - transactionEndImageUri: 'end.jpg', - baselineStartImageUri: 'start.jpg', - baselineEndImageUri: 'end.jpg', hasTransactionData: true, hasLocalState: true, hasInitialized: true, @@ -31,9 +27,9 @@ describe('isExternalOdometerResync', () => { expect(isExternalOdometerResync(buildState({transactionStartValue: '150'}))).toBe(true); }); - it('is true when only an image was changed externally', () => { - expect(isExternalOdometerResync(buildState({transactionStartImageUri: 'new-start.jpg'}))).toBe(true); - }); + // Image changes are intentionally NOT part of this predicate: it slides the readings baseline, so reacting to an + // image-only change would re-baseline still-unsent readings and silently drop the discard prompt. The image-change + // discard signal is handled separately by the screen against a never-slid image baseline. it('is false before on-mount initialization, even if values differ', () => { expect(isExternalOdometerResync(buildState({hasInitialized: false, transactionStartValue: '150'}))).toBe(false); From df15a9c29abdd6113d044fbc2463c288979ea5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 11:19:49 +0200 Subject: [PATCH 38/42] refactor(navigation): drop redundant memoization in OnyxTabNavigator --- src/libs/Navigation/OnyxTabNavigator.tsx | 96 +++++++++++------------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 8fd0735d718b..7c28504997bf 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -2,7 +2,7 @@ import type {MaterialTopTabNavigationEventMap} from '@react-navigation/material- import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; import type {EventArg, EventMapCore, NavigationProp, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; import {TabActions, useRoute} from '@react-navigation/native'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; import ActivityIndicator from '@components/ActivityIndicator'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; @@ -113,7 +113,7 @@ function OnyxTabNavigator({ const [focusTrapContainerElementMapping, setFocusTrapContainerElementMapping] = useState>({}); const [selectedTab, selectedTabResult] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`); - const tabNames = useMemo(() => getTabNames(children), [children]); + const tabNames = getTabNames(children); const validInitialTab = selectedTab && tabNames.includes(selectedTab) ? selectedTab : defaultSelectedTab; @@ -129,7 +129,7 @@ function OnyxTabNavigator({ }, [styles.fullScreenLoading, styles.w100]); // This callback is used to register the focus trap container element of each available tab screen - const setTabFocusTrapContainerElement = useCallback((tabName: string, containerElement: HTMLElement | null) => { + const setTabFocusTrapContainerElement = (tabName: string, containerElement: HTMLElement | null) => { setFocusTrapContainerElementMapping((prevMapping) => { const resultMapping = {...prevMapping}; if (containerElement) { @@ -139,7 +139,7 @@ function OnyxTabNavigator({ } return resultMapping; }); - }, []); + }; const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); @@ -147,7 +147,7 @@ function OnyxTabNavigator({ const guardsRef = useRef>(new Map()); const isDiscardModalOpenRef = useRef(false); - const registerTabGuard = useCallback((guard) => { + const registerTabGuard: RegisterTabSwitchGuard = (guard) => { guardsRef.current.set(guard.tabName, guard); return () => { // Only clear if this exact guard is still registered, so a re-registration from another mount isn't wiped. @@ -156,49 +156,46 @@ function OnyxTabNavigator({ } guardsRef.current.delete(guard.tabName); }; - }, []); + }; - const handleTabPress = useCallback( - (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { - if (isDiscardModalOpenRef.current) { - event.preventDefault(); - return; - } - const navState = navigation.getState(); - const currentRouteName = navState.routes.at(navState.index)?.name; - const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; - if (!guard || !guard.getHasUnsavedChanges()) { - return; - } - const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); - if (!targetRoute || targetRoute.name === currentRouteName) { + const handleTabPress = (navigation: NavigationProp, event: EventArg<'tabPress', true, undefined>) => { + if (isDiscardModalOpenRef.current) { + event.preventDefault(); + return; + } + const navState = navigation.getState(); + const currentRouteName = navState.routes.at(navState.index)?.name; + const guard = currentRouteName ? guardsRef.current.get(currentRouteName) : undefined; + if (!guard || !guard.getHasUnsavedChanges()) { + return; + } + const targetRoute = navState.routes.find((tabRoute) => tabRoute.key === event.target); + if (!targetRoute || targetRoute.name === currentRouteName) { + return; + } + event.preventDefault(); + isDiscardModalOpenRef.current = true; + showConfirmModal({ + ...getDiscardChangesModalConfig(translate), + shouldIgnoreBackHandlerDuringTransition: true, + }).then((result) => { + isDiscardModalOpenRef.current = false; + if (result.action !== ModalActions.CONFIRM) { + guard.onCancel?.(); return; } - event.preventDefault(); - isDiscardModalOpenRef.current = true; - showConfirmModal({ - ...getDiscardChangesModalConfig(translate), - shouldIgnoreBackHandlerDuringTransition: true, - }).then((result) => { - isDiscardModalOpenRef.current = false; - if (result.action !== ModalActions.CONFIRM) { - guard.onCancel?.(); - return; - } - // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. - Promise.resolve() - .then(() => guard.onDiscard()) - .catch((error: unknown) => { - Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); - Growl.error(translate('common.genericErrorMessage')); - }) - .then(() => { - navigation.dispatch(TabActions.jumpTo(targetRoute.name)); - }); - }); - }, - [showConfirmModal, translate], - ); + // User confirmed: always jump to the target tab, even if onDiscard fails, rather than stranding them with no feedback. + Promise.resolve() + .then(() => guard.onDiscard()) + .catch((error: unknown) => { + Log.warn('[OnyxTabNavigator] Failed to run tab-switch onDiscard callback', {error}); + Growl.error(translate('common.genericErrorMessage')); + }) + .then(() => { + navigation.dispatch(TabActions.jumpTo(targetRoute.name)); + }); + }); + }; /** * This is a TabBar wrapper component that includes the focus trap container element callback. @@ -306,12 +303,9 @@ function TabScreenWithFocusTrapWrapper({children}: {children?: React.ReactNode}) const route = useRoute(); const styles = useThemeStyles(); const setTabContainerElement = useContext(TabFocusTrapContext); - const handleContainerElementChanged = useCallback( - (element: HTMLElement | null) => { - setTabContainerElement(route.name, element); - }, - [setTabContainerElement, route.name], - ); + const handleContainerElementChanged = (element: HTMLElement | null) => { + setTabContainerElement(route.name, element); + }; return ( Date: Thu, 25 Jun 2026 11:22:42 +0200 Subject: [PATCH 39/42] revert(odometer): drop vestigial tabBar wrapper in DistanceRequestStartPage The tabBar={(props) => } wrapper was only needed by the old page-level DistanceTabGuardContext to capture navigation/state. That guard moved into OnyxTabNavigator's generic tab-switch guard, so the wrapper is now a no-op identical to passing TabSelector directly. Revert to tabBar={TabSelector}, matching every other caller. Zero net diff vs main. --- src/pages/iou/request/DistanceRequestStartPage.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/pages/iou/request/DistanceRequestStartPage.tsx b/src/pages/iou/request/DistanceRequestStartPage.tsx index c9c8bab8dd90..f8641c64524a 100644 --- a/src/pages/iou/request/DistanceRequestStartPage.tsx +++ b/src/pages/iou/request/DistanceRequestStartPage.tsx @@ -1,10 +1,9 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import TabSelector from '@components/TabSelector/TabSelector'; -import type {TabSelectorProps} from '@components/TabSelector/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -98,8 +97,6 @@ function DistanceRequestStartPage({ return [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement].filter((element) => !!element); }, [headerWithBackBtnContainerElement, tabBarContainerElement, activeTabContainerElement]); - const tabBar = useCallback((tabBarProps: TabSelectorProps) => , []); - return ( Date: Thu, 25 Jun 2026 11:46:35 +0200 Subject: [PATCH 40/42] refactor(odometer): merge resync helpers into OdometerUtils and rename from OdometerImageUtil Move isExternalOdometerResync, shouldInitializeOdometerFromTransaction, and OdometerResyncState out of the page-level odometerResync.ts into src/libs. Rename OdometerImageUtils.ts -> OdometerUtils.ts to hold all odometer pure utils. Update all importers and the unit test. No behavior change. --- src/hooks/useOdometerReceiptStitcher/index.ts | 2 +- .../index.ts | 2 +- src/libs/OdometerImageUtils.ts | 64 ------------------ .../OdometerReceipt/deriveOdometerReceipt.ts | 2 +- src/libs/OdometerReceipt/stitchTask.ts | 2 +- .../OdometerUtils.ts} | 65 ++++++++++++++++++- src/libs/actions/OdometerTransactionUtils.ts | 2 +- src/libs/actions/TransactionEdit.ts | 2 +- src/libs/stitchOdometerImages/index.native.ts | 2 +- src/libs/stitchOdometerImages/index.ts | 2 +- .../hooks/useOdometerReadingsState.ts | 4 +- .../step/IOURequestStepDistanceOdometer.tsx | 2 +- .../index.native.tsx | 2 +- tests/actions/OdometerTransactionUtilsTest.ts | 6 +- ...erResync.test.ts => OdometerUtils.test.ts} | 4 +- .../hooks/useOdometerReceiptStitcherTest.tsx | 2 +- .../useRestartOnOdometerImagesFailure.test.ts | 2 +- 17 files changed, 83 insertions(+), 84 deletions(-) delete mode 100644 src/libs/OdometerImageUtils.ts rename src/{pages/iou/request/step/IOURequestStepDistance/odometerResync.ts => libs/OdometerUtils.ts} (50%) rename tests/unit/{odometerResync.test.ts => OdometerUtils.test.ts} (95%) diff --git a/src/hooks/useOdometerReceiptStitcher/index.ts b/src/hooks/useOdometerReceiptStitcher/index.ts index 83360b3505e1..870671ab0a54 100644 --- a/src/hooks/useOdometerReceiptStitcher/index.ts +++ b/src/hooks/useOdometerReceiptStitcher/index.ts @@ -3,8 +3,8 @@ import {useEffect, useReducer, useRef} from 'react'; import useLocalize from '@hooks/useLocalize'; import useRestartOnOdometerImagesFailure from '@hooks/useRestartOnOdometerImagesFailure'; import Log from '@libs/Log'; -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; import {deriveOdometerReceipt, stitchTask} from '@libs/OdometerReceipt'; +import {getOdometerImageUri} from '@libs/OdometerUtils'; import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt'; import type {FileObject} from '@src/types/utils/Attachment'; import type {OdometerReceiptState, UseOdometerReceiptStitcherArgs, UseOdometerReceiptStitcherResult} from './types'; diff --git a/src/hooks/useRestartOnOdometerImagesFailure/index.ts b/src/hooks/useRestartOnOdometerImagesFailure/index.ts index 75b5e13edc25..ef5d2d30e57a 100644 --- a/src/hooks/useRestartOnOdometerImagesFailure/index.ts +++ b/src/hooks/useRestartOnOdometerImagesFailure/index.ts @@ -4,7 +4,7 @@ import useOnyx from '@hooks/useOnyx'; import {checkIfLocalFileIsAccessible} from '@libs/actions/IOU/Receipt'; import clearOdometerDraftTransactionState, {hydrateOdometerDraftIntoTransaction} from '@libs/actions/OdometerTransactionUtils'; import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils'; -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageUri} from '@libs/OdometerUtils'; import type {IOUType} from '@src/CONST'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts deleted file mode 100644 index a4500588d4a2..000000000000 --- a/src/libs/OdometerImageUtils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import CONST from '@src/CONST'; -import type {FileObject} from '@src/types/utils/Attachment'; -import {getMimeTypeFromUri} from './fileDownload/FileUtils'; -import getPlatform from './getPlatform'; - -function getOdometerImageUri(image: FileObject | string | null | undefined): string { - return typeof image === 'string' ? image : (image?.uri ?? ''); -} - -function getOdometerImageName(image: FileObject | string | null | undefined): string { - return typeof image === 'string' ? (image.split('/').pop() ?? '') : (image?.name ?? ''); -} - -/** - * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. - * `name|size|lastModified` is preserved across the draft round-trip, so it stays stable on resume/reload while still - * changing for a real swap (`lastModified` disambiguates a different file that happens to share name + size). - * When `lastModified` is missing we fall back to the uri ONLY on native, where the `file://` uri is durable, unique per - * selection (rand64 suffix), and stable across the round-trip (no blob re-mint) - so a same-name/same-size swap stays - * detectable. On web the uri is a volatile `blob:` that re-mints on every resume/reload, so including it would fire a - * false discard prompt; there we use `name|size` alone. - */ -function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { - if (!image) { - return ''; - } - if (typeof image === 'string') { - return image; - } - const base = `${image.name ?? ''}|${image.size ?? ''}`; - if (image.lastModified !== undefined) { - return `${base}|${image.lastModified}`; - } - // No lastModified: only native's durable file:// uri is a safe way to tell images apart. On web the uri is a volatile blob: - // that re-mints on resume/reload, so name|size alone keeps the identity stable and avoids a false discard prompt. - return getPlatform() === CONST.PLATFORM.WEB ? base : `${base}|${image.uri ?? ''}`; -} - -function getOdometerImageType(image: FileObject | string | null | undefined): string | undefined { - return typeof image === 'string' ? getMimeTypeFromUri(image) : (image?.type ?? getMimeTypeFromUri(image?.uri ?? '')); -} - -/** - * Revokes a blob URL previously associated with an odometer image, but only when - * the image has actually changed (i.e. the old URL differs from the new one) - */ -function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { - if (typeof URL === 'undefined') { - return; - } - - const currentUri = getOdometerImageUri(image); - if (!currentUri?.startsWith('blob:')) { - return; - } - const nextUri = getOdometerImageUri(nextImage); - if (currentUri === nextUri) { - return; - } - URL.revokeObjectURL(currentUri); -} - -export {getOdometerImageUri, getOdometerImageName, getOdometerImageType, getOdometerImageIdentity}; -export default revokeOdometerImageUri; diff --git a/src/libs/OdometerReceipt/deriveOdometerReceipt.ts b/src/libs/OdometerReceipt/deriveOdometerReceipt.ts index 602da87ebe0b..504bea554cd7 100644 --- a/src/libs/OdometerReceipt/deriveOdometerReceipt.ts +++ b/src/libs/OdometerReceipt/deriveOdometerReceipt.ts @@ -1,4 +1,4 @@ -import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerUtils'; import type {FileObject} from '@src/types/utils/Attachment'; type OdometerReceiptDerivation = diff --git a/src/libs/OdometerReceipt/stitchTask.ts b/src/libs/OdometerReceipt/stitchTask.ts index f02465bb54cf..d692c56fe8af 100644 --- a/src/libs/OdometerReceipt/stitchTask.ts +++ b/src/libs/OdometerReceipt/stitchTask.ts @@ -1,4 +1,4 @@ -import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageName, getOdometerImageType, getOdometerImageUri} from '@libs/OdometerUtils'; import stitchOdometerImages from '@libs/stitchOdometerImages'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; import CONST from '@src/CONST'; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts b/src/libs/OdometerUtils.ts similarity index 50% rename from src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts rename to src/libs/OdometerUtils.ts index d517905b222a..e7c6aacaf5b5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/odometerResync.ts +++ b/src/libs/OdometerUtils.ts @@ -1,3 +1,65 @@ +import CONST from '@src/CONST'; +import type {FileObject} from '@src/types/utils/Attachment'; +import {getMimeTypeFromUri} from './fileDownload/FileUtils'; +import getPlatform from './getPlatform'; + +function getOdometerImageUri(image: FileObject | string | null | undefined): string { + return typeof image === 'string' ? image : (image?.uri ?? ''); +} + +function getOdometerImageName(image: FileObject | string | null | undefined): string { + return typeof image === 'string' ? (image.split('/').pop() ?? '') : (image?.name ?? ''); +} + +/** + * A re-mint-invariant identity for an odometer image, used in the discard-changes baseline diff. + * `name|size|lastModified` is preserved across the draft round-trip, so it stays stable on resume/reload while still + * changing for a real swap (`lastModified` disambiguates a different file that happens to share name + size). + * When `lastModified` is missing we fall back to the uri ONLY on native, where the `file://` uri is durable, unique per + * selection (rand64 suffix), and stable across the round-trip (no blob re-mint) - so a same-name/same-size swap stays + * detectable. On web the uri is a volatile `blob:` that re-mints on every resume/reload, so including it would fire a + * false discard prompt; there we use `name|size` alone. + */ +function getOdometerImageIdentity(image: FileObject | string | null | undefined): string { + if (!image) { + return ''; + } + if (typeof image === 'string') { + return image; + } + const base = `${image.name ?? ''}|${image.size ?? ''}`; + if (image.lastModified !== undefined) { + return `${base}|${image.lastModified}`; + } + // No lastModified: only native's durable file:// uri is a safe way to tell images apart. On web the uri is a volatile blob: + // that re-mints on resume/reload, so name|size alone keeps the identity stable and avoids a false discard prompt. + return getPlatform() === CONST.PLATFORM.WEB ? base : `${base}|${image.uri ?? ''}`; +} + +function getOdometerImageType(image: FileObject | string | null | undefined): string | undefined { + return typeof image === 'string' ? getMimeTypeFromUri(image) : (image?.type ?? getMimeTypeFromUri(image?.uri ?? '')); +} + +/** + * Revokes a blob URL previously associated with an odometer image, but only when + * the image has actually changed (i.e. the old URL differs from the new one) + */ +function revokeOdometerImageUri(image: FileObject | string | null | undefined, nextImage?: FileObject | string | null): void { + if (typeof URL === 'undefined') { + return; + } + + const currentUri = getOdometerImageUri(image); + if (!currentUri?.startsWith('blob:')) { + return; + } + const nextUri = getOdometerImageUri(nextImage); + if (currentUri === nextUri) { + return; + } + URL.revokeObjectURL(currentUri); +} + /** * Pure decision helpers for the "resync local odometer readings from the transaction" effect in * `IOURequestStepDistanceOdometer` @@ -64,5 +126,6 @@ function shouldInitializeOdometerFromTransaction(state: OdometerResyncState, isE ); } -export {isExternalOdometerResync, shouldInitializeOdometerFromTransaction}; +export {getOdometerImageUri, getOdometerImageName, getOdometerImageType, getOdometerImageIdentity, isExternalOdometerResync, shouldInitializeOdometerFromTransaction}; export type {OdometerResyncState}; +export default revokeOdometerImageUri; diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index 604bd76dea0c..c60bb6397fc2 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -3,7 +3,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {base64ToFile, convertFileObjectOrUriToBase64DataURL} from '@libs/fileDownload/FileUtils'; import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; -import revokeOdometerImageUri, {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import revokeOdometerImageUri, {getOdometerImageUri} from '@libs/OdometerUtils'; import CONST from '@src/CONST'; import type {OdometerImageType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index 19dd5b73d737..671e0580d387 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -2,7 +2,7 @@ import {format} from 'date-fns'; import Onyx from 'react-native-onyx'; import type {Connection, OnyxEntry} from 'react-native-onyx'; import {formatCurrentUserToAttendee} from '@libs/IOUUtils'; -import revokeOdometerImageUri from '@libs/OdometerImageUtils'; +import revokeOdometerImageUri from '@libs/OdometerUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetails, Transaction} from '@src/types/onyx'; diff --git a/src/libs/stitchOdometerImages/index.native.ts b/src/libs/stitchOdometerImages/index.native.ts index 0e54df6f91ad..dedd45ec2ed4 100644 --- a/src/libs/stitchOdometerImages/index.native.ts +++ b/src/libs/stitchOdometerImages/index.native.ts @@ -1,7 +1,7 @@ import {ImageFormat, Skia} from '@shopify/react-native-skia'; import RNFS from 'react-native-fs'; import Log from '@libs/Log'; -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageUri} from '@libs/OdometerUtils'; import type {FileObject} from '@src/types/utils/Attachment'; import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; diff --git a/src/libs/stitchOdometerImages/index.ts b/src/libs/stitchOdometerImages/index.ts index 27885c8ff993..5266ecbf332f 100644 --- a/src/libs/stitchOdometerImages/index.ts +++ b/src/libs/stitchOdometerImages/index.ts @@ -1,4 +1,4 @@ -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageUri} from '@libs/OdometerUtils'; import type {FileObject} from '@src/types/utils/Attachment'; import STITCHED_ODOMETER_FILENAME_PREFIX from './constants'; import calculateStitchLayout from './stitchLayout'; diff --git a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts index 9588f4906cfd..2af90c10e2c5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -1,8 +1,8 @@ import {useEffect, useRef, useState} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {isOdometerDraftPendingHydration} from '@libs/actions/OdometerTransactionUtils'; -import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; -import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; +import type {OdometerResyncState} from '@libs/OdometerUtils'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@libs/OdometerUtils'; import CONST from '@src/CONST'; import type {OdometerDraft, Transaction} from '@src/types/onyx'; import type {FileObject} from '@src/types/utils/Attachment'; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index bfed1f1679c3..d0aa891f6f2f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -42,7 +42,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; -import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; +import {getOdometerImageIdentity} from '@libs/OdometerUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtils} from '@libs/ReportUtils'; import shouldUseDefaultExpensePolicyUtil from '@libs/shouldUseDefaultExpensePolicy'; import {startSpan} from '@libs/telemetry/activeSpans'; diff --git a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx index eeeb44346198..420bb5484210 100644 --- a/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepOdometerImage/index.native.tsx @@ -31,7 +31,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Log from '@libs/Log'; import moveReceiptToDurableStorage from '@libs/moveReceiptToDurableStorage'; import Navigation from '@libs/Navigation/Navigation'; -import {getOdometerImageUri} from '@libs/OdometerImageUtils'; +import {getOdometerImageUri} from '@libs/OdometerUtils'; import {cancelSpan, endSpan, startSpan} from '@libs/telemetry/activeSpans'; import NavigationAwareCamera from '@pages/iou/request/step/IOURequestStepScan/components/NavigationAwareCamera/Camera'; import {cropImageToAspectRatio} from '@pages/iou/request/step/IOURequestStepScan/cropImageToAspectRatio'; diff --git a/tests/actions/OdometerTransactionUtilsTest.ts b/tests/actions/OdometerTransactionUtilsTest.ts index 384228f142d8..03aa26d1f43b 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -12,7 +12,7 @@ import { import type {OdometerUnsavedChangesState} from '@libs/actions/OdometerTransactionUtils'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import getPlatform from '@libs/getPlatform'; -import {getOdometerImageIdentity} from '@libs/OdometerImageUtils'; +import {getOdometerImageIdentity} from '@libs/OdometerUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -142,14 +142,14 @@ describe('actions/OdometerTransactionUtils', () => { describe('setMoneyRequestOdometerImage and removeMoneyRequestOdometerImage', () => { beforeEach(() => { - jest.mock('@libs/OdometerImageUtils', () => ({ + jest.mock('@libs/OdometerUtils', () => ({ __esModule: true, default: jest.fn(), })); }); afterEach(() => { - jest.unmock('@libs/OdometerImageUtils'); + jest.unmock('@libs/OdometerUtils'); }); it('should set odometer start image on a draft transaction', () => { const transaction = createRandomTransaction(1); diff --git a/tests/unit/odometerResync.test.ts b/tests/unit/OdometerUtils.test.ts similarity index 95% rename from tests/unit/odometerResync.test.ts rename to tests/unit/OdometerUtils.test.ts index f2de8b4e96d5..2801f9cd8503 100644 --- a/tests/unit/odometerResync.test.ts +++ b/tests/unit/OdometerUtils.test.ts @@ -1,5 +1,5 @@ -import type {OdometerResyncState} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; -import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@pages/iou/request/step/IOURequestStepDistance/odometerResync'; +import type {OdometerResyncState} from '@libs/OdometerUtils'; +import {isExternalOdometerResync, shouldInitializeOdometerFromTransaction} from '@libs/OdometerUtils'; // A steady state: initialized, transaction and local readings match, nothing being typed. const STEADY_STATE: OdometerResyncState = { diff --git a/tests/unit/hooks/useOdometerReceiptStitcherTest.tsx b/tests/unit/hooks/useOdometerReceiptStitcherTest.tsx index 9c7752aaff1c..0e16f1b37b73 100644 --- a/tests/unit/hooks/useOdometerReceiptStitcherTest.tsx +++ b/tests/unit/hooks/useOdometerReceiptStitcherTest.tsx @@ -57,7 +57,7 @@ jest.mock('@libs/Log', () => ({ // OdometerImageUtils is used inline by the hook. Stub it to break the transitive import chain // (FileUtils → saveLastRoute → API → ...). The hook only needs getOdometerImageUri. -jest.mock('@libs/OdometerImageUtils', () => ({ +jest.mock('@libs/OdometerUtils', () => ({ __esModule: true, getOdometerImageUri: (image: unknown) => { if (typeof image === 'string') { diff --git a/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts index 6bf5018458a7..499c704509ce 100644 --- a/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts +++ b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts @@ -33,7 +33,7 @@ jest.mock('@libs/IOUUtils', () => ({ })); // Keep getOdometerImageUri real (mirrors the real impl) but break the FileUtils -> API transitive chain. -jest.mock('@libs/OdometerImageUtils', () => ({ +jest.mock('@libs/OdometerUtils', () => ({ __esModule: true, default: jest.fn(), getOdometerImageUri: (image: {uri?: string} | string | null | undefined) => (typeof image === 'string' ? image : (image?.uri ?? '')), From b483157dfa93faae8023cb3654c79157b5a00d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 16:00:44 +0200 Subject: [PATCH 41/42] fix(odometer): defer blob verification until draft re-mint lands In the saved-for-later recovery path, hasVerifiedBlobs flipped true before hydrateOdometerDraftIntoTransaction's Onyx.merge landed, letting the readings hook snapshot its image baseline from the stale dead-blob image. A later swap to the re-minted image then read as a phantom "Discard changes?". Flip verification and navigate only after the merge resolves. --- .../useRestartOnOdometerImagesFailure/index.ts | 17 +++++++++++------ src/libs/actions/OdometerTransactionUtils.ts | 6 +++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/hooks/useRestartOnOdometerImagesFailure/index.ts b/src/hooks/useRestartOnOdometerImagesFailure/index.ts index ef5d2d30e57a..fc5883bbb570 100644 --- a/src/hooks/useRestartOnOdometerImagesFailure/index.ts +++ b/src/hooks/useRestartOnOdometerImagesFailure/index.ts @@ -103,15 +103,20 @@ const useRestartOnOdometerImagesFailure = ( // Rehydrate over the dead URLs when a draft exists — clearing first races the destination's // auto-hydrator and ends up dropping the wrong URL. if (odometerDraft) { - // Restore images from draft, mark verified, and tell the backup hook not to revert on unmount - setAsyncVerificationPassed(true); + // Tell the backup hook not to revert on unmount, then re-mint the images from the draft. Only flip + // verification (and navigate) AFTER the merge lands, else the readings hook snapshots its baseline from + // the stale dead-blob image and a later swap to the re-minted image reads as a phantom "Discard changes?". onBackupHandled?.({shouldResetLocalState: false}); - hydrateOdometerDraftIntoTransaction(transaction.transactionID, odometerDraft, transaction.comment); - } else { - onBackupHandled?.({shouldResetLocalState: true}); - clearOdometerDraftTransactionState(transaction); + hydrateOdometerDraftIntoTransaction(transaction.transactionID, odometerDraft, transaction.comment).then(() => { + setAsyncVerificationPassed(true); + navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport); + }); + return; } + onBackupHandled?.({shouldResetLocalState: true}); + clearOdometerDraftTransactionState(transaction); + navigateToStartMoneyRequestStep(CONST.IOU.REQUEST_TYPE.DISTANCE_ODOMETER, iouType, transaction.transactionID, reportID, CONST.IOU.ACTION.CREATE, backToReport); }); }, [draftTransactionsMetadata, transaction, iouType, reportID, backToReport, onBackupHandled, odometerDraft, odometerDraftStatus]); diff --git a/src/libs/actions/OdometerTransactionUtils.ts b/src/libs/actions/OdometerTransactionUtils.ts index c60bb6397fc2..d091f8354842 100644 --- a/src/libs/actions/OdometerTransactionUtils.ts +++ b/src/libs/actions/OdometerTransactionUtils.ts @@ -205,12 +205,12 @@ function buildOdometerCommentFromDraft(transactionID: string, odometerDraft: Ony * odometer tab) so blob URLs can be re-minted from the persisted base64 after a page refresh. * Returns silently when the draft is empty or the comment already reflects it. */ -function hydrateOdometerDraftIntoTransaction(transactionID: string, odometerDraft: OnyxEntry, currentComment?: Partial): void { +function hydrateOdometerDraftIntoTransaction(transactionID: string, odometerDraft: OnyxEntry, currentComment?: Partial): Promise { const update = buildOdometerCommentFromDraft(transactionID, odometerDraft, currentComment); if (!update) { - return; + return Promise.resolve(); } - Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: update}); + return Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {comment: update}); } /** From 375d63bd2e7caef45bfcfcf0e6bd27936425f3c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Kali=C5=84ski?= Date: Thu, 25 Jun 2026 16:07:53 +0200 Subject: [PATCH 42/42] test(odometer): return a resolved promise from hydrate mock The recovery path now awaits hydrateOdometerDraftIntoTransaction() before flipping verification (commit b483157), but the test mock returned undefined, so .then() threw and hasVerifiedBlobs stayed false. Return Promise.resolve() to match the real Promise signature. --- tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts index 499c704509ce..ac5dea4a1d96 100644 --- a/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts +++ b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts @@ -24,7 +24,10 @@ const mockClear = jest.fn(); jest.mock('@libs/actions/OdometerTransactionUtils', () => ({ __esModule: true, default: (...args: unknown[]) => mockClear(...args), - hydrateOdometerDraftIntoTransaction: (...args: unknown[]) => mockHydrate(...args), + hydrateOdometerDraftIntoTransaction: (...args: unknown[]) => { + mockHydrate(...args); + return Promise.resolve(); + }, })); const mockNavigateToStartMoneyRequestStep = jest.fn();