diff --git a/cspell.json b/cspell.json index 23eeb3cf2f33..7a89ec9815e2 100644 --- a/cspell.json +++ b/cspell.json @@ -830,6 +830,7 @@ "reimbursability", "reimbursementid", "reimbursible", + "reminted", "remotedesktop", "remotesync", "removeHiddenElems", diff --git a/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts new file mode 100644 index 000000000000..203ca42c42af --- /dev/null +++ b/src/hooks/useDiscardChangesConfirmation/getDiscardChangesModalConfig.ts @@ -0,0 +1,18 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +/** + * 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 { + 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 9f0def0a2e34..22e78df5376b 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -1,5 +1,5 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useFocusEffect, useIsFocused, usePreventRemove} from '@react-navigation/native'; +import {useFocusEffect, useIsFocused, usePreventRemove, useRoute} from '@react-navigation/native'; import {useRef} from 'react'; import {BackHandler} from 'react-native'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -7,10 +7,19 @@ 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 {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation { +function useDiscardChangesConfirmation({ + getHasUnsavedChanges, + onCancel, + onVisibilityChange, + onConfirm, + onTabSwitchDiscard, +}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation { + const route = useRoute(); const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); const blockedNavigationAction = useRef(undefined); @@ -25,17 +34,15 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); + // Also guard tab switches when this screen is an OnyxTabNavigator tab. + // Self-disables outside a tab navigator or without an onTabSwitchDiscard handler + useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); + const showDiscardModal = (blockedAction?: NavigationAction) => { blockedNavigationAction.current = blockedAction; isDiscardModalOpen.current = true; 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) => { isDiscardModalOpen.current = false; onVisibilityChange?.(false); if (result.action !== ModalActions.CONFIRM) { diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 09920d850d39..2153586c6362 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 {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native'; import {useEffect, useRef} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useBeforeRemove from '@hooks/useBeforeRemove'; @@ -8,12 +8,25 @@ import useLocalize from '@hooks/useLocalize'; import Log from '@libs/Log'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigationRef from '@libs/Navigation/navigationRef'; +import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext'; +import getDiscardChangesModalConfig from './getDiscardChangesModalConfig'; import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation { +function useDiscardChangesConfirmation({ + getHasUnsavedChanges, + onCancel, + onVisibilityChange, + onConfirm, + onTabSwitchDiscard, +}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation { + const route = useRoute(); const {translate} = useLocalize(); const {showConfirmModal, closeModal} = useConfirmModal(); + + // Also guard tab switches when this screen is an OnyxTabNavigator tab. + // Self-disables outside a tab navigator or without an onTabSwitchDiscard handler + useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel); const blockedNavigationAction = useRef(undefined); const shouldNavigateBack = useRef(false); const isDiscardModalOpen = useRef(false); @@ -42,11 +55,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, shouldHandleNavigationBack: false, }).then((result) => { diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index 339e4bafaf03..5c35f489c4e4 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -4,6 +4,12 @@ type UseDiscardChangesConfirmationOptions = { onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; onConfirm?: () => void | Promise; + + /** + * Discard action for confirming a tab switch. Provide it to guard tab switches inside an `OnyxTabNavigator`. + * Can differ from `onConfirm` (nav-away) + */ + onTabSwitchDiscard?: () => void | Promise; }; type DiscardChangesConfirmation = { 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 15161ac30274..fc5883bbb570 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'; @@ -103,13 +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) { + // 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/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 48e864a80d8f..fa59c6b6a8cb 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -1,14 +1,20 @@ 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 React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import type {EventArg, EventMapCore, NavigationProp, NavigationState, ParamListBase, ScreenListeners} from '@react-navigation/native'; +import {TabActions, useRoute} from '@react-navigation/native'; +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'; +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 Growl from '@libs/Growl'; +import Log from '@libs/Log'; import Tab from '@userActions/Tab'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -16,6 +22,8 @@ import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {backBehavior, 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 */ @@ -105,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; @@ -121,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) { @@ -131,7 +139,63 @@ function OnyxTabNavigator({ } return resultMapping; }); - }, []); + }; + + 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: 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. + if (guardsRef.current.get(guard.tabName) !== guard) { + return; + } + guardsRef.current.delete(guard.tabName); + }; + }; + + 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; + } + // 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. @@ -161,46 +225,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} + + + ); } @@ -225,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 ( 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}; diff --git a/src/libs/OdometerImageUtils.ts b/src/libs/OdometerImageUtils.ts deleted file mode 100644 index 940036384f22..000000000000 --- a/src/libs/OdometerImageUtils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type {FileObject} from '@src/types/utils/Attachment'; -import {getMimeTypeFromUri} from './fileDownload/FileUtils'; - -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 ?? ''); -} - -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) - */ -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}; -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/libs/OdometerUtils.ts b/src/libs/OdometerUtils.ts new file mode 100644 index 000000000000..e7c6aacaf5b5 --- /dev/null +++ b/src/libs/OdometerUtils.ts @@ -0,0 +1,131 @@ +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` + * + * 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 = { + /** 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; + + /** 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; +}; + +/** + * 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; +} + +/** + * 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 (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.isUserTyping) || + (state.hasTransactionData && !state.hasLocalState && state.hasInitialized && !state.isUserTyping) || + isExternalResync + ); +} + +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 231c660f882c..d091f8354842 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'; @@ -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: { @@ -50,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]; @@ -123,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; } @@ -139,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}); @@ -155,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); @@ -177,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); @@ -198,29 +205,72 @@ 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}); } /** - * 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 worth a "Discard changes?" prompt. Both checks ignore + * 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) { + return false; + } + const hasImageChanges = state.transactionStartImageUri !== state.baselineStartImageUri || state.transactionEndImageUri !== state.baselineEndImageUri; + // 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); + if (!state.isUserTyping && !hasImageChanges && !!state.odometerDraft && draftReadingsMatchTransaction && !isOdometerDraftPendingHydration(state.odometerDraft, state.currentComment)) { + return false; + } + return state.hasReadingChanges || hasImageChanges; } export { @@ -231,5 +281,7 @@ export { saveOdometerDraft, hydrateOdometerDraftIntoTransaction, isOdometerDraftPendingHydration, + getOdometerHasUnsavedChanges, }; +export type {OdometerUnsavedChangesState}; export default clearOdometerDraftTransactionState; 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 ce623bbddacc..2af90c10e2c5 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts +++ b/src/pages/iou/request/step/IOURequestStepDistance/hooks/useOdometerReadingsState.ts @@ -1,6 +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 '@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'; @@ -9,20 +11,23 @@ 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; + + /** 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 = { @@ -53,22 +58,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; }; @@ -79,6 +84,7 @@ function useOdometerReadingsState({ isLoadingSelectedTab, hasVerifiedBlobs, odometerDraft, + userHasUnsavedTypingRef, }: UseOdometerReadingsStateParams): UseOdometerReadingsStateResult { const [startReading, setStartReading] = useState(''); const [endReading, setEndReading] = useState(''); @@ -126,8 +132,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 +142,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,12 +154,14 @@ 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, no edit tracking needed. 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,37 +173,44 @@ 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) + // 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 resyncState: OdometerResyncState = { + transactionStartValue: startValue, + transactionEndValue: endValue, + localStartValue: startReadingRef.current, + localEndValue: endReadingRef.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; + } - // 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; - - 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; - } + // 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, isEditing]); + }, [currentTransaction?.comment?.odometerStart, currentTransaction?.comment?.odometerEnd, isEditing, userHasUnsavedTypingRef]); return { startReading, 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 0fb1ab5ba4c3..d0aa891f6f2f 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -34,7 +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, 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'; @@ -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 {getOdometerImageUri} 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'; @@ -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); @@ -178,7 +181,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; @@ -195,6 +198,7 @@ function IOURequestStepDistanceOdometer({ transactionID, didSaveEditingConfirmationRef, backupHandledManuallyRef: backupHandledManually, + recoveryHandledBackupRef, }); const navigateToNextStep = useOdometerNavigation({ @@ -268,11 +272,7 @@ 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. + // Per-keystroke validation: enforce format constraints and cap the max value const isOdometerInputValid = (text: string, previousText: string): boolean => { if (!text) { return true; @@ -288,7 +288,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) { @@ -424,7 +424,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; } @@ -483,6 +483,54 @@ function IOURequestStepDistanceOdometer({ navigateToNextPage(); }; + 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 = () => { + 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(''); + }; + + const restoreLastInputFocus = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + lastFocusedInputRef.current?.focus(); + }); + }, []); + + useDiscardChangesConfirmation({ + getHasUnsavedChanges, + onCancel: restoreLastInputFocus, + onConfirm: isEditingConfirmation + ? async () => { + await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); + backupHandledManually.current = true; + } + : undefined, + onTabSwitchDiscard: handleTabSwitchDiscard, + onVisibilityChange: setIsDiscardModalVisible, + }); + const handleSaveForLater = useCallback(async () => { shouldBypassDiscardConfirmationRef.current = true; @@ -516,38 +564,6 @@ function IOURequestStepDistanceOdometer({ Navigation.closeRHPFlow(); }, [fromLocaleDigit, startReading, endReading, odometerStartImage, odometerEndImage, translate, setFormError]); - useDiscardChangesConfirmation({ - onCancel: () => { - InteractionManager.runAfterInteractions(() => { - 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; - }, - onConfirm: isEditingConfirmation - ? async () => { - await restoreOriginalTransactionFromBackupWithImageCleanup(transactionID, isTransactionDraft); - backupHandledManually.current = true; - } - : undefined, - onVisibilityChange: setIsDiscardModalVisible, - }); - return ( & {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 6bd75c60e426..03aa26d1f43b 100644 --- a/tests/actions/OdometerTransactionUtilsTest.ts +++ b/tests/actions/OdometerTransactionUtilsTest.ts @@ -2,8 +2,17 @@ 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, + saveOdometerDraft, + setMoneyRequestOdometerImage, +} 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/OdometerUtils'; import type * as PolicyUtils from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -56,7 +65,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(), @@ -99,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; @@ -130,81 +142,348 @@ 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', 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 () => { - const transaction = { - ...createRandomTransaction(1), + it('should remove odometer start image from a draft transaction', () => { + 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; - 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 () => { - const transaction = { - ...createRandomTransaction(1), + it('should remove odometer end image from a non-draft transaction', () => { + 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; - await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, transaction); + 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(); + }); + }); + }); + + 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); + }); + + 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}, + 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); + }); - removeMoneyRequestOdometerImage(transaction, imageType, false, false); - await waitForBatchedUpdates(); + 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'}, + 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}, + 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)', () => { + // 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(''); + }); + + // 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)); + }); + + // 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)); + }); + + // 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'); + }); + + // 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)); + }); - const updatedTransaction = await getOnyxValue(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); - expect(updatedTransaction?.comment?.odometerEndImage).toBeUndefined(); + // 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/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/IOURequestStepAmountDraftTest.tsx b/tests/ui/IOURequestStepAmountDraftTest.tsx index 818f141dba22..1a51b61e7c46 100644 --- a/tests/ui/IOURequestStepAmountDraftTest.tsx +++ b/tests/ui/IOURequestStepAmountDraftTest.tsx @@ -114,6 +114,7 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), useFocusEffect: jest.fn(), usePreventRemove: jest.fn(), + useRoute: jest.fn(() => ({name: 'Money_Request_Step_Amount'})), }; }); diff --git a/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx new file mode 100644 index 000000000000..76af73dcd0b9 --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerBackupTest.tsx @@ -0,0 +1,573 @@ +/* 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, 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 {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'; +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}), +})); + +// 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(() => ({})), +})); + +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(() => ({key: 'distance-odometer', name: 'Money_Request_Step_Distance_Odometer', params: {}})), + }; +}); + +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; + +// 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, + 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, + }, + }, + }; +} + +// 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(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}`); + 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 +// 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: +// - 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, 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]}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await waitForBatchedUpdates(); + await signInWithTestUser(ACCOUNT_ID, ACCOUNT_LOGIN); + }); + + function renderWithCapturedGuard(register: RegisterTabSwitchGuard) { + 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; onDiscard: () => Promise; transaction: Transaction}> { + const transaction = createOdometerTransaction(); + if (startImage) { + transaction.comment = {...transaction.comment, odometerStartImage: startImage}; + } + 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.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, + 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(); + + // 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 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); + + 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, lastModified: 1000}}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + 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/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx new file mode 100644 index 000000000000..6f5e11843566 --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerNextSyncTest.tsx @@ -0,0 +1,329 @@ +/* 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 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'; +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(() => ({key: 'distance-odometer', name: 'Money_Request_Distance_Create', params: {}})), + }; +}); + +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, + }, + }, + }; +} + +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, + }; +} + +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..df5607e4fd86 --- /dev/null +++ b/tests/ui/IOURequestStepDistanceOdometerSflResumeTest.tsx @@ -0,0 +1,238 @@ +/* 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 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'; +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(() => ({key: 'distance-odometer', name: 'Money_Request_Distance_Create', params: {}})), +})); + +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}, + }, + }; +} + +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, + }; +} + +function renderCreateFlow(register: RegisterTabSwitchGuard) { + 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: TabSwitchGuard | 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: TabSwitchGuard | 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/ui/IOURequestStepDistanceTest.tsx b/tests/ui/IOURequestStepDistanceTest.tsx index 9c99b46c5a3e..8e924e2c8e18 100644 --- a/tests/ui/IOURequestStepDistanceTest.tsx +++ b/tests/ui/IOURequestStepDistanceTest.tsx @@ -158,7 +158,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(() => ({name: 'Money_Request_Step_Distance'})), }; }); diff --git a/tests/ui/components/IOURequestStepDistanceTest.tsx b/tests/ui/components/IOURequestStepDistanceTest.tsx index 76bd75ca8b5a..6fc467d4f5b6 100644 --- a/tests/ui/components/IOURequestStepDistanceTest.tsx +++ b/tests/ui/components/IOURequestStepDistanceTest.tsx @@ -112,6 +112,7 @@ jest.mock('@react-navigation/native', () => { useNavigation: () => ({navigate: jest.fn(), addListener: jest.fn()}), useFocusEffect: jest.fn(), usePreventRemove: jest.fn(), + useRoute: jest.fn(() => ({name: 'Money_Request_Step_Distance'})), }; }); diff --git a/tests/unit/OdometerUtils.test.ts b/tests/unit/OdometerUtils.test.ts new file mode 100644 index 000000000000..2801f9cd8503 --- /dev/null +++ b/tests/unit/OdometerUtils.test.ts @@ -0,0 +1,85 @@ +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 = { + transactionStartValue: '100', + transactionEndValue: '200', + localStartValue: '100', + localEndValue: '200', + 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); + }); + + // 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); + }); + + 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('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); + }); + + 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); + }); +}); diff --git a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts index a54da8f713ba..73acba1b473f 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts @@ -19,6 +19,8 @@ jest.mock('@hooks/useBeforeRemove', () => ({ let mockIsFocused = true; jest.mock('@react-navigation/native', () => ({ useIsFocused: () => mockIsFocused, + // The hook reads `route.name` to key its tab-switch guard + useRoute: () => ({name: 'test-route'}), useFocusEffect: (callback: () => undefined | (() => void)) => { jest.requireActual<{useEffect: (effect: () => undefined | (() => void), deps: unknown[]) => void}>('react').useEffect(callback, []); }, diff --git a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts index d9fec4619e87..d6c326e97d89 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts @@ -14,6 +14,8 @@ jest.mock('@react-navigation/native', () => ({ mockPreventRemoveCallback = callback; }, useIsFocused: () => mockIsFocused, + // The hook reads `route.name` to key its tab-switch guard + useRoute: () => ({name: 'test-route'}), // Focus effects behave like plain effects in these tests — the screen is always focused useFocusEffect: (callback: () => undefined | (() => void)) => { jest.requireActual<{useEffect: (effect: () => undefined | (() => void), deps: unknown[]) => void}>('react').useEffect(callback, [callback]); diff --git a/tests/unit/hooks/useOdometerReadingsState.test.ts b/tests/unit/hooks/useOdometerReadingsState.test.ts index 077d9ad09f20..d5361f6c39a3 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(), @@ -29,6 +37,7 @@ const baseParams: Params = { isLoadingSelectedTab: false, hasVerifiedBlobs: true, odometerDraft: undefined, + userHasUnsavedTypingRef: {current: false}, }; describe('useOdometerReadingsState', () => { @@ -39,7 +48,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(''); @@ -47,6 +56,61 @@ 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'); + }); + + // 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)); @@ -67,19 +131,85 @@ describe('useOdometerReadingsState', () => { const {result} = renderHook(() => useOdometerReadingsState({ ...baseParams, - odometerDraft: {odometerStartReading: 999} as unknown as OnyxTypes.OdometerDraft, + odometerDraft: {odometerStartReading: 999}, }), ); 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}, + }), + ); + + 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'}, + }), + ); + + 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}, + }), + ); + + 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}, + }), + ); + + 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', - 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})); @@ -127,4 +257,17 @@ 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 = buildOdometerTransaction({odometerStart: undefined, odometerEnd: undefined}); + 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/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/useOdometerTransactionBackup.test.ts b/tests/unit/hooks/useOdometerTransactionBackup.test.ts new file mode 100644 index 000000000000..da31d4abfcb0 --- /dev/null +++ b/tests/unit/hooks/useOdometerTransactionBackup.test.ts @@ -0,0 +1,128 @@ +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 createRandomTransaction from '../../utils/collections/transaction'; +import getOnyxValue from '../../utils/getOnyxValue'; +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +const TRANSACTION_ID = 'odometer-backup-test'; + +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, + ...commentOverrides, + }, + }; +}; + +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}, + recoveryHandledBackupRef: {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(); + }); + + // 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); + 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); + }); +}); diff --git a/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts new file mode 100644 index 000000000000..ac5dea4a1d96 --- /dev/null +++ b/tests/unit/hooks/useRestartOnOdometerImagesFailure.test.ts @@ -0,0 +1,129 @@ +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); + return Promise.resolve(); + }, +})); + +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/OdometerUtils', () => ({ + __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(); + }); +});