diff --git a/patches/react-navigation/@react-navigation+core+7.16.1+003+propagate-beforeremove-on-nested-reset.patch b/patches/react-navigation/@react-navigation+core+7.16.1+003+propagate-beforeremove-on-nested-reset.patch new file mode 100644 index 000000000000..55cd0b4395c9 --- /dev/null +++ b/patches/react-navigation/@react-navigation+core+7.16.1+003+propagate-beforeremove-on-nested-reset.patch @@ -0,0 +1,89 @@ +diff --git a/node_modules/@react-navigation/core/lib/module/useOnPreventRemove.js b/node_modules/@react-navigation/core/lib/module/useOnPreventRemove.js +index 5f6044e..1029d81 100644 +--- a/node_modules/@react-navigation/core/lib/module/useOnPreventRemove.js ++++ b/node_modules/@react-navigation/core/lib/module/useOnPreventRemove.js +@@ -5,10 +5,7 @@ import { NavigationBuilderContext } from "./NavigationBuilderContext.js"; + import { NavigationRouteContext } from "./NavigationProvider.js"; + const VISITED_ROUTE_KEYS = Symbol('VISITED_ROUTE_KEYS'); + export const shouldPreventRemove = (emitter, beforeRemoveListeners, currentRoutes, nextRoutes, action) => { +- const nextRouteKeys = nextRoutes.map(route => route.key); +- +- // Call these in reverse order so last screens handle the event first +- const removedRoutes = currentRoutes.filter(route => !nextRouteKeys.includes(route.key)).reverse(); ++ const nextRoutesByKey = new Map(nextRoutes.map(route => [route.key, route]).filter(([, route]) => route.key != null)); + const visitedRouteKeys = + // @ts-expect-error: add this property to mark that we've already emitted this action + action[VISITED_ROUTE_KEYS] ?? new Set(); +@@ -16,28 +13,42 @@ export const shouldPreventRemove = (emitter, beforeRemoveListeners, currentRoute + ...action, + [VISITED_ROUTE_KEYS]: visitedRouteKeys + }; +- for (const route of removedRoutes) { ++ ++ // Call these in reverse order so last screens handle the event first ++ const reversedCurrentRoutes = [...currentRoutes].reverse(); ++ for (const route of reversedCurrentRoutes) { + if (visitedRouteKeys.has(route.key)) { + // Skip if we've already emitted this action for this screen + continue; + } +- +- // First, we need to check if any child screens want to prevent it +- const isPrevented = beforeRemoveListeners[route.key]?.(beforeRemoveAction); +- if (isPrevented) { +- return true; +- } +- visitedRouteKeys.add(route.key); +- const event = emitter.emit({ +- type: 'beforeRemove', +- target: route.key, +- data: { +- action: beforeRemoveAction +- }, +- canPreventDefault: true +- }); +- if (event.defaultPrevented) { +- return true; ++ if (!nextRoutesByKey.has(route.key)) { ++ // The route is not in next state, so it's being removed ++ // First, we need to check if any child screens want to prevent it ++ const isPrevented = beforeRemoveListeners[route.key]?.(beforeRemoveAction, undefined); ++ if (isPrevented) { ++ return true; ++ } ++ visitedRouteKeys.add(route.key); ++ const event = emitter.emit({ ++ type: 'beforeRemove', ++ target: route.key, ++ data: { ++ action: beforeRemoveAction ++ }, ++ canPreventDefault: true ++ }); ++ if (event.defaultPrevented) { ++ return true; ++ } ++ } else { ++ // The route is kept but its nested state changed, so propagate the check into the nested navigator ++ const nextRoute = nextRoutesByKey.get(route.key); ++ if (route.state != null && route.state !== nextRoute?.state) { ++ const isPrevented = beforeRemoveListeners[route.key]?.(beforeRemoveAction, nextRoute?.state); ++ if (isPrevented) { ++ return true; ++ } ++ } + } + } + return false; +@@ -54,9 +65,9 @@ export function useOnPreventRemove({ + const routeKey = route?.key; + React.useEffect(() => { + if (routeKey) { +- return addKeyedListener?.('beforeRemove', routeKey, action => { ++ return addKeyedListener?.('beforeRemove', routeKey, (action, nextState) => { + const state = getState(); +- return shouldPreventRemove(emitter, beforeRemoveListeners, state.routes, [], action); ++ return shouldPreventRemove(emitter, beforeRemoveListeners, state.routes, nextState?.routes ?? [], action); + }); + } + }, [addKeyedListener, beforeRemoveListeners, emitter, getState, routeKey]); diff --git a/patches/react-navigation/details.md b/patches/react-navigation/details.md index 2c7e824771a4..663224c11e8f 100644 --- a/patches/react-navigation/details.md +++ b/patches/react-navigation/details.md @@ -32,6 +32,15 @@ - PR Introducing Patch: [#65836](https://github.com/Expensify/App/pull/65836) - PR Updating Patch: N/A +### [@react-navigation+core+7.16.1+003+propagate-beforeremove-on-nested-reset.patch](@react-navigation+core+7.16.1+003+propagate-beforeremove-on-nested-reset.patch) + +- Reason: Browser back on web dispatches a root-targeted `RESET` that keeps route keys and only changes nested state, silently bypassing `usePreventRemove`/`beforeRemove` and losing unsaved data. The patch propagates the check into nested navigators. +- Upstream PR: https://github.com/react-navigation/react-navigation/pull/13153 +- Upstream issue: https://github.com/react-navigation/react-navigation/issues/9031 +- E/App issue: [#84246](https://github.com/Expensify/App/issues/84246) +- PR Introducing Patch: [#93268](https://github.com/Expensify/App/pull/93268) +- PR Updating Patch: N/A + ### [@react-navigation+native-stack+7.14.5+001+added-interaction-manager-integration.patch](@react-navigation+native-stack+7.14.5+001+added-interaction-manager-integration.patch) - Reason: Adds `InteractionManager` implementation to `@react-navigation/native-stack` diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index f44dd33c6231..9f0def0a2e34 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -1,65 +1,101 @@ import type {NavigationAction} from '@react-navigation/native'; -import {usePreventRemove} from '@react-navigation/native'; -import {useCallback, useRef, useState} from 'react'; +import {useFocusEffect, useIsFocused, usePreventRemove} from '@react-navigation/native'; +import {useRef} from 'react'; +import {BackHandler} from 'react-native'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import Log from '@libs/Log'; import navigationRef from '@libs/Navigation/navigationRef'; +import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation { const {translate} = useLocalize(); const {showConfirmModal} = useConfirmModal(); - const [shouldAllowNavigation, setShouldAllowNavigation] = useState(false); const blockedNavigationAction = useRef(undefined); + const isDiscardModalOpen = useRef(false); + const isReplayingBlockedNavigation = useRef(false); - const shouldPrevent = !shouldAllowNavigation; + // Only the focused screen should prompt — a flow-leave reset fires `beforeRemove` for hidden siblings too. + const isFocused = useIsFocused(); + const isSavingRef = useRef(false); + useFocusEffect(() => { + isSavingRef.current = false; + }); + const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); - usePreventRemove( - shouldPrevent, - useCallback( - ({data}: {data: {action: NavigationAction}}) => { - if (!getHasUnsavedChanges()) { - setShouldAllowNavigation(true); - navigationRef.current?.dispatch(data.action); - return; + 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) => { + isDiscardModalOpen.current = false; + onVisibilityChange?.(false); + if (result.action !== ModalActions.CONFIRM) { + blockedNavigationAction.current = undefined; + onCancel?.(); + return; + } + const confirmNavigation = () => { + isReplayingBlockedNavigation.current = true; + if (blockedNavigationAction.current) { + navigationRef.current?.dispatch(blockedNavigationAction.current); + blockedNavigationAction.current = undefined; + } else { + navigationRef.current?.goBack(); } - blockedNavigationAction.current = data.action; - onVisibilityChange?.(true); - showConfirmModal({ - title: translate('discardChangesConfirmation.title'), - prompt: translate('discardChangesConfirmation.body'), - danger: true, - confirmText: translate('discardChangesConfirmation.confirmText'), - cancelText: translate('common.cancel'), - }).then((result) => { - onVisibilityChange?.(false); - if (result.action !== ModalActions.CONFIRM) { - onCancel?.(); - return; - } - const confirmNavigation = () => { - setShouldAllowNavigation(true); - if (blockedNavigationAction.current) { - navigationRef.current?.dispatch(blockedNavigationAction.current); - blockedNavigationAction.current = undefined; - } else { - navigationRef.current?.goBack(); - } - }; - Promise.resolve() - .then(() => onConfirm?.()) - .then(confirmNavigation) - .catch((error: unknown) => { - Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); - blockedNavigationAction.current = undefined; - }); + isReplayingBlockedNavigation.current = false; + }; + Promise.resolve() + .then(() => onConfirm?.()) + .then(confirmNavigation) + .catch((error: unknown) => { + Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error}); + blockedNavigationAction.current = undefined; }); - }, - [getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm, showConfirmModal, translate], - ), - ); + }); + }; + + usePreventRemove(true, ({data}: {data: {action: NavigationAction}}) => { + // The action delivered here carries react-navigation's visited-routes marker, so re-dispatching it skips this screen's prevention + if (isReplayingBlockedNavigation.current || !hasUnsavedChanges()) { + navigationRef.current?.dispatch(data.action); + return; + } + if (isDiscardModalOpen.current) { + return; + } + showDiscardModal(data.action); + }); + + // A tab-switch hardware back is an index-only TabRouter change that never fires `beforeRemove`, so intercept it here, + // ahead of react-navigation's container handler (BackHandler runs listeners newest-first). + useFocusEffect(() => { + const subscription = BackHandler.addEventListener('hardwareBackPress', () => { + if (isDiscardModalOpen.current) { + return true; + } + if (!hasUnsavedChanges()) { + return false; + } + showDiscardModal(); + return true; + }); + return () => subscription.remove(); + }); + + const notifySaving = (isSaving = true) => { + isSavingRef.current = isSaving; + }; + + return {notifySaving}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 2ca6c1a86724..f06f06e3e7b5 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -1,62 +1,45 @@ import type {NavigationAction} from '@react-navigation/native'; -import {useNavigation} from '@react-navigation/native'; -import {useCallback, useEffect, useRef} from 'react'; +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; import {ModalActions} from '@components/Modal/Global/ModalContext'; +import {isInternalPopstateInProgress} from '@components/Modal/internalPopstateGuard'; import useBeforeRemove from '@hooks/useBeforeRemove'; import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import Log from '@libs/Log'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import navigationRef from '@libs/Navigation/navigationRef'; -import type {PlatformStackNavigationProp} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {RootNavigatorParamList} from '@libs/Navigation/types'; +import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; -function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions) { - const navigation = useNavigation>(); +function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibilityChange, onConfirm}: UseDiscardChangesConfirmationOptions): DiscardChangesConfirmation { const {translate} = useLocalize(); - const {showConfirmModal} = useConfirmModal(); + const {showConfirmModal, closeModal} = useConfirmModal(); const blockedNavigationAction = useRef(undefined); const shouldNavigateBack = useRef(false); - const shouldIgnoreNextBeforeRemove = useRef(false); - const clearShouldIgnoreNextBeforeRemoveTimeout = useRef | undefined>(undefined); const isDiscardModalOpen = useRef(false); + const isRestoringHistory = useRef(false); + const didPreventResetOnPopstate = useRef(false); + const shouldDismissModalOnRestore = useRef(false); - const clearShouldIgnoreNextBeforeRemove = useCallback(() => { - if (clearShouldIgnoreNextBeforeRemoveTimeout.current) { - clearTimeout(clearShouldIgnoreNextBeforeRemoveTimeout.current); - clearShouldIgnoreNextBeforeRemoveTimeout.current = undefined; - } - shouldIgnoreNextBeforeRemove.current = false; - }, []); - - const markNextBeforeRemoveAsModalCleanup = useCallback(() => { - if ((window.history.state as {shouldGoBack?: boolean} | null)?.shouldGoBack !== true) { - return; - } - - shouldIgnoreNextBeforeRemove.current = true; - if (clearShouldIgnoreNextBeforeRemoveTimeout.current) { - clearTimeout(clearShouldIgnoreNextBeforeRemoveTimeout.current); - } - clearShouldIgnoreNextBeforeRemoveTimeout.current = setTimeout(() => { - shouldIgnoreNextBeforeRemove.current = false; - clearShouldIgnoreNextBeforeRemoveTimeout.current = undefined; - }, 250); - }, []); + // Only the focused screen should prompt — a flow-leave reset fires `beforeRemove` for hidden siblings too. + const isFocused = useIsFocused(); + const isSavingRef = useRef(false); + useFocusEffect(() => { + isSavingRef.current = false; + }); + const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); - const navigateBack = useCallback(() => { - if (blockedNavigationAction.current) { - navigationRef.current?.dispatch(blockedNavigationAction.current); - return; - } - if (!shouldNavigateBack.current) { + const navigateBack = () => { + if (!blockedNavigationAction.current) { return; } - navigationRef.current?.goBack(); - }, []); + shouldNavigateBack.current = true; + navigationRef.current?.dispatch(blockedNavigationAction.current); + shouldNavigateBack.current = false; + }; - const showDiscardModal = useCallback(() => { + const showDiscardModal = () => { isDiscardModalOpen.current = true; onVisibilityChange?.(true); showConfirmModal({ @@ -66,9 +49,11 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi confirmText: translate('discardChangesConfirmation.confirmText'), cancelText: translate('common.cancel'), shouldIgnoreBackHandlerDuringTransition: true, + shouldHandleNavigationBack: false, }).then((result) => { - markNextBeforeRemoveAsModalCleanup(); isDiscardModalOpen.current = false; + didPreventResetOnPopstate.current = false; + shouldDismissModalOnRestore.current = false; onVisibilityChange?.(false); if (result.action === ModalActions.CONFIRM) { Promise.resolve() @@ -87,54 +72,83 @@ function useDiscardChangesConfirmation({getHasUnsavedChanges, onCancel, onVisibi onCancel?.(); } }); - }, [showConfirmModal, translate, navigateBack, onCancel, onConfirm, onVisibilityChange, markNextBeforeRemoveAsModalCleanup]); + }; useBeforeRemove((e) => { - const hasUnsavedChanges = getHasUnsavedChanges(); - if (!hasUnsavedChanges) { - clearShouldIgnoreNextBeforeRemove(); + if (isRestoringHistory.current) { + // The `history.go(1)` restoring the browser entry can re-deliver a reset for the current state; swallow it without re-blocking + e.preventDefault(); + return; + } + + if (!hasUnsavedChanges()) { return; } - if (isDiscardModalOpen.current || shouldIgnoreNextBeforeRemove.current) { - clearShouldIgnoreNextBeforeRemove(); + if (isDiscardModalOpen.current) { e.preventDefault(); + if (e.data.action.type === 'RESET') { + didPreventResetOnPopstate.current = true; + shouldDismissModalOnRestore.current = true; + return; + } + closeModal(); return; } if (shouldNavigateBack.current) { - clearShouldIgnoreNextBeforeRemove(); return; } e.preventDefault(); blockedNavigationAction.current = e.data.action; + if (e.data.action.type === 'RESET') { + // A prevented RESET comes from a browser back; the popstate listener must restore the URL + didPreventResetOnPopstate.current = true; + } showDiscardModal(); }); + // `closeModal` changes every render, so the once-registered popstate listener reads it through a ref + const closeModalRef = useRef(closeModal); + useEffect(() => { + closeModalRef.current = closeModal; + }); + /** - * We cannot programmatically stop the browser's back navigation like react-navigation's beforeRemove. - * Events like popstate and transitionStart are triggered AFTER the back navigation has already completed. - * So we need to go forward to get back to the current page. + * Browser back is blocked by the patched `beforeRemove` before the state commits, but the browser entry has + * already moved — this listener restores it with `history.go(1)`, and dismisses the prompt as Cancel when the back happened over it. */ useEffect(() => { - const unsubscribe = navigation.addListener('transitionStart', ({data: {closing}}) => { - if (!getHasUnsavedChanges()) { + // Register exactly once: re-registering on render would move this listener behind `withInternalPopstate`'s one-shot flag reset, breaking the internal-popstate detection + const handlePopState = () => { + if (isInternalPopstateInProgress()) { return; } - shouldNavigateBack.current = true; - if (closing) { - window.history.go(1); + if (isRestoringHistory.current) { + isRestoringHistory.current = false; return; } - window.history.go(1); - showDiscardModal(); - }); + if (didPreventResetOnPopstate.current) { + didPreventResetOnPopstate.current = false; + isRestoringHistory.current = true; + window.history.go(1); + if (shouldDismissModalOnRestore.current) { + shouldDismissModalOnRestore.current = false; + closeModalRef.current(); + } + } + }; + + window.addEventListener('popstate', handlePopState); + return () => window.removeEventListener('popstate', handlePopState); + }, []); - return unsubscribe; - }, [navigation, getHasUnsavedChanges, showDiscardModal]); + const notifySaving = (isSaving = true) => { + isSavingRef.current = isSaving; + }; - useEffect(() => clearShouldIgnoreNextBeforeRemove, [clearShouldIgnoreNextBeforeRemove]); + return {notifySaving}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index abfcbc6b76fd..339e4bafaf03 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -1,8 +1,15 @@ type UseDiscardChangesConfirmationOptions = { + /** Returns whether the screen has unsaved changes. The hook already gates on focus and an in-progress save, so this should report only real dirtiness. */ getHasUnsavedChanges: () => boolean; onCancel?: () => void; onVisibilityChange?: (visible: boolean) => void; onConfirm?: () => void | Promise; }; +type DiscardChangesConfirmation = { + /** Suppress the discard prompt while an intentional save navigates away. Pass `false` to clear it if the save aborts without navigating. */ + notifySaving: (isSaving?: boolean) => void; +}; + export default UseDiscardChangesConfirmationOptions; +export type {DiscardChangesConfirmation}; diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index b87a155c1653..48e864a80d8f 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -15,7 +15,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {SelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import {defaultScreenOptions} from './OnyxTabNavigatorConfig'; +import {backBehavior, defaultScreenOptions} from './OnyxTabNavigatorConfig'; type OnyxTabNavigatorProps = ChildrenProps & { /** ID of the tab component to be saved in onyx */ @@ -166,7 +166,7 @@ function OnyxTabNavigator({ {...rest} id={id} initialRouteName={validInitialTab} - backBehavior="initialRoute" + backBehavior={backBehavior} keyboardDismissMode="none" tabBar={TabBarWithFocusTrapInclusion} onTabSelect={onTabSelect} diff --git a/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts index c1b2ccc1ce38..99f1ecded851 100644 --- a/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts +++ b/src/libs/Navigation/OnyxTabNavigatorConfig/index.ts @@ -1,8 +1,10 @@ +import type {TabRouterOptions} from '@react-navigation/native'; + const defaultScreenOptions = { animation: 'default', } as const; -export { - // eslint-disable-next-line import/prefer-default-export - defaultScreenOptions, -}; +/** On native there is no browser history; hardware back returns to the initial tab first, per platform convention. */ +const backBehavior: NonNullable = 'initialRoute'; + +export {defaultScreenOptions, backBehavior}; diff --git a/src/libs/Navigation/OnyxTabNavigatorConfig/index.web.ts b/src/libs/Navigation/OnyxTabNavigatorConfig/index.web.ts index 320021670b82..718384676ee6 100644 --- a/src/libs/Navigation/OnyxTabNavigatorConfig/index.web.ts +++ b/src/libs/Navigation/OnyxTabNavigatorConfig/index.web.ts @@ -1,8 +1,13 @@ +import type {TabRouterOptions} from '@react-navigation/native'; + const defaultScreenOptions = { animation: 'none', } as const; -export { - // eslint-disable-next-line import/prefer-default-export - defaultScreenOptions, -}; +/** + * `none` keeps the tab history at a single entry, so `useLinking` replaces the browser entry instead of + * pushing one — tab switches add no history and the browser back button leaves the whole flow. + */ +const backBehavior: NonNullable = 'none'; + +export {defaultScreenOptions, backBehavior}; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 2fd0e3f451ff..cb6736c88411 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1230,6 +1230,14 @@ function hasDisplayableMCC(mcc: number | string | null | undefined): boolean { /** * Return the waypoints field from the transaction, return the modifiedWaypoints if present. */ +/** + * Whether a draft holds tab-entered input that is lost when the flow is abandoned (drafts are not restored on the next open). + * Forward-navigation fields (amount, receipt, ...) are deliberately excluded; extend per-field as new tabs persist input to the draft. + */ +function doesMoneyRequestDraftHaveUserInput(transaction: OnyxEntry): boolean { + return Object.keys(getValidWaypoints(getWaypoints(transaction))).length > 0; +} + function getWaypoints(transaction: OnyxEntry): WaypointCollection | undefined { return transaction?.modifiedWaypoints ?? transaction?.comment?.waypoints; } @@ -3012,6 +3020,7 @@ export { isReceiptBeingScanned, didReceiptScanSucceed, getValidWaypoints, + doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isDistanceRequest, isMapDistanceRequest, diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 5735d32ae467..59004379ca96 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -1,4 +1,5 @@ -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react'; +import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; @@ -25,7 +26,15 @@ import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; type CurrentMoney = {amount: string; currency: string; paymentMethod?: PaymentMethodType}; +type MoneyRequestAmountFormHandle = { + /** Returns the currently typed (unsaved) amount, signed the same way the submit handler would send it */ + getNumber: () => string; +}; + type MoneyRequestAmountFormProps = Omit & { + /** Exposes the currently typed amount to the parent (e.g. for unsaved-changes detection) */ + amountFormRef?: ForwardedRef; + /** Calculated tax amount based on selected tax rate */ taxAmount?: number; @@ -97,6 +106,7 @@ function MoneyRequestAmountForm({ hideCurrencySymbol = false, allowFlippingAmount = false, isP2P = false, + amountFormRef, ref, }: MoneyRequestAmountFormProps) { const styles = useThemeStyles(); @@ -109,6 +119,13 @@ function MoneyRequestAmountForm({ const [isNegative, setIsNegative] = useState(false); + useImperativeHandle(amountFormRef, () => ({ + getNumber: () => { + const number = moneyRequestAmountInputRef.current?.getNumber() ?? ''; + return number && isNegative ? `-${number}` : number; + }, + })); + const [formError, setFormError] = useState(''); const formattedTaxAmount = convertToDisplayString(Math.abs(taxAmount), currency); @@ -310,4 +327,4 @@ function MoneyRequestAmountForm({ } export default MoneyRequestAmountForm; -export type {CurrentMoney}; +export type {CurrentMoney, MoneyRequestAmountFormHandle}; diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 099fab54250d..e03283ffe901 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -9,6 +9,7 @@ import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -18,12 +19,14 @@ import useReportIsArchived from '@hooks/useReportIsArchived'; import useReportOrReportDraft from '@hooks/useReportOrReportDraft'; import useSelfDMReport from '@hooks/useSelfDMReport'; import useShowNotFoundPageInIOUStep from '@hooks/useShowNotFoundPageInIOUStep'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getExistingTransactionID} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, shouldEnableNegative} from '@libs/ReportUtils'; import {getRequestType, isDistanceRequest, isExpenseUnreported} from '@libs/TransactionUtils'; import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; +import type {MoneyRequestAmountFormHandle} from '@pages/iou/MoneyRequestAmountForm'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -60,6 +63,7 @@ function IOURequestStepAmount({ const delegateAccountID = useDelegateAccountID(); const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); const textInput = useRef(null); + const amountFormRef = useRef(null); const focusTimeoutRef = useRef(null); const iouRequestType = getRequestType(transaction); const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK; @@ -118,6 +122,17 @@ function IOURequestStepAmount({ const [selectedCurrency, setSelectedCurrency] = useState(originalCurrency); const decimals = getCurrencyDecimals(selectedCurrency || CONST.CURRENCY.USD); + const {notifySaving} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => { + const typedAmount = amountFormRef.current?.getNumber() ?? ''; + const typedAmountInBackendUnits = typedAmount ? convertToBackendAmount(Number.parseFloat(typedAmount)) : 0; + return typedAmountInBackendUnits !== transactionAmount || selectedCurrency !== originalCurrency; + }, + onCancel: () => { + focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); + }, + }); + const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, transaction); const isUnreportedDistanceExpense = isEditing && isDistanceRequest(transaction) && isExpenseUnreported(transaction); @@ -156,6 +171,7 @@ function IOURequestStepAmount({ }; const handleSubmit = ({amount, paymentMethod}: {amount: string; paymentMethod?: PaymentMethodType}) => { + notifySaving(); submitAmount({ report, transaction, @@ -229,6 +245,7 @@ function IOURequestStepAmount({ isEditing={!!backTo || isEditing} currency={selectedCurrency} amount={transactionAmount} + amountFormRef={amountFormRef} skipConfirmation={shouldSkipConfirmation ?? false} iouType={iouType} policyID={policy?.id} diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 22dd35223b61..b8f5ede917d8 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -13,6 +13,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useFetchRoute from '@hooks/useFetchRoute'; import useLocalize from '@hooks/useLocalize'; @@ -42,7 +43,7 @@ import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import {roundToTwoDecimalPlaces} from '@libs/NumberUtils'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {getDistanceInMeters, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, getDistanceInMeters, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -155,6 +156,10 @@ function IOURequestStepDistance({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); + const {notifySaving} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => isCreatingNewRequest && doesMoneyRequestDraftHaveUserInput(transaction), + }); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); // Manual distance editing state @@ -493,6 +498,7 @@ function IOURequestStepDistance({ return; } + notifySaving(); navigateToNextStep(); }, [ duplicateWaypointsError, @@ -504,6 +510,7 @@ function IOURequestStepDistance({ isCreatingNewRequest, navigateToNextStep, navigateBackAfterSave, + notifySaving, isEditingSplit, originalSplitTransactionDraft, transactionBackup, diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 0ca84fa421eb..68c01ba48463 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -9,6 +9,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -143,6 +144,16 @@ function IOURequestStepDistanceManual({ const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : unit); const distance = typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)) : undefined; + const {notifySaving} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => { + const typedDistance = numberFormRef.current?.getNumber() ?? ''; + return typedDistance !== (distance?.toString() ?? ''); + }, + onCancel: () => { + focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION); + }, + }); + let shouldSkipConfirmation = false; if (skipConfirmation && report?.reportID) { shouldSkipConfirmation = !(isArchived || isPolicyExpenseChatUtils(report)); @@ -283,6 +294,7 @@ function IOURequestStepDistanceManual({ return; } + notifySaving(); navigateToNextPage(value); }; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx index a355a3cf69f3..794f0ea39335 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceMap.tsx @@ -10,6 +10,7 @@ import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalD import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDelegateAccountID from '@hooks/useDelegateAccountID'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useDistanceRateOriginalPolicy from '@hooks/useDistanceRateOriginalPolicy'; import useFetchRoute from '@hooks/useFetchRoute'; import useLocalize from '@hooks/useLocalize'; @@ -34,7 +35,7 @@ import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {shouldUseTransactionDraft} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isPolicyExpenseChat as isPolicyExpenseChatUtil} from '@libs/ReportUtils'; -import {getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, getRateID, getRequestType, haveWaypointAddressesChanged} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -138,6 +139,10 @@ function IOURequestStepDistanceMap({ const shouldShowNotFoundPage = useShowNotFoundPageInIOUStep(action, iouType, reportActionID, report, currentTransaction); + const {notifySaving} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => isCreatingNewRequest && doesMoneyRequestDraftHaveUserInput(transaction), + }); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); @@ -361,6 +366,7 @@ function IOURequestStepDistanceMap({ return; } + notifySaving(); navigateToNextStep(); }, [ duplicateWaypointsError, @@ -371,6 +377,7 @@ function IOURequestStepDistanceMap({ isLoading, isCreatingNewRequest, navigateToNextStep, + notifySaving, isEditingSplit, transaction, transactionBackup, diff --git a/src/pages/iou/request/step/IOURequestStepHours.tsx b/src/pages/iou/request/step/IOURequestStepHours.tsx index 47c657660af9..f4144bd57d6d 100644 --- a/src/pages/iou/request/step/IOURequestStepHours.tsx +++ b/src/pages/iou/request/step/IOURequestStepHours.tsx @@ -6,6 +6,7 @@ import type {NumberWithSymbolFormRef} from '@components/NumberWithSymbolForm'; import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -79,6 +80,16 @@ function IOURequestStepHours({ moneyRequestTimeInputRef.current?.updateNumber(`${transaction?.comment?.units?.count ?? ''}`); }, [selectedTab, transaction?.comment?.units?.count]); + const {notifySaving} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => { + const typedCount = moneyRequestTimeInputRef.current?.getNumber() ?? ''; + return typedCount !== `${transaction?.comment?.units?.count ?? ''}`; + }, + onCancel: () => { + focusTimeoutRef.current = setTimeout(() => textInputRef.current?.focus(), CONST.ANIMATED_TRANSITION); + }, + }); + useFocusEffect(() => { focusTimeoutRef.current = setTimeout(() => textInputRef.current?.focus(), CONST.ANIMATED_TRANSITION); return () => { @@ -102,6 +113,7 @@ function IOURequestStepHours({ return; } + notifySaving(); setMoneyRequestAmount(transactionID, computeTimeAmount(rate, count), currency); setMoneyRequestMerchant(transactionID, formatTimeMerchant(count, rate, currency, translate, convertToDisplayString), isTransactionDraft); setMoneyRequestTimeCount(transactionID, count, isTransactionDraft); @@ -170,6 +182,7 @@ function IOURequestStepHours({ style={[styles.w100, canUseTouchScreen ? styles.mt5 : styles.mt0]} onPress={() => { if (policy && shouldRestrictUserBillableActions(policy, ownerBillingGracePeriodEnd, userBillingGracePeriodEnds, amountOwed, accountID)) { + notifySaving(); Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policy.id)); return; } diff --git a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx index e78f1d0d2645..155783630e55 100644 --- a/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx +++ b/src/pages/settings/Agents/Fields/EditAgentAvatarPage.tsx @@ -1,4 +1,4 @@ -import React, {useMemo, useRef, useState} from 'react'; +import React, {useMemo, useState} from 'react'; import {View} from 'react-native'; import AttachmentPicker from '@components/AttachmentPicker'; import Avatar from '@components/Avatar'; @@ -73,11 +73,10 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false); const [errorData, setErrorData] = useState<{validationError: TranslationPaths | null; phraseParam: Record}>({validationError: null, phraseParam: {}}); - const isSavingRef = useRef(false); const isDirty = selectedBotAvatar !== initialBotAvatar || imageData.uri !== ''; - useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => !isSavingRef.current && isDirty, + const {notifySaving} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => isDirty, }); let previewSource: AvatarSource = personalDetails?.avatar ?? ''; @@ -123,7 +122,7 @@ function EditAgentAvatarContent({accountID, fallbackRoute, onSave, initialPreset if (!isDirty) { return; } - isSavingRef.current = true; + notifySaving(); if (imageData.file) { if (onSave) { diff --git a/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx b/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx index bdc7c99c76ff..3239ede9e65e 100644 --- a/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx +++ b/src/pages/settings/Profile/Avatar/EditUserAvatarContent.tsx @@ -30,7 +30,6 @@ function EditUserAvatarContent() { const [selected, setSelected] = useState(); const avatarCaptureRef = useRef(null); - const isSavingRef = useRef(false); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -40,8 +39,8 @@ function EditUserAvatarContent() { const isDirty = imageData.uri !== '' || !!selected; - useDiscardChangesConfirmation({ - getHasUnsavedChanges: () => !isSavingRef.current && isDirty, + const {notifySaving} = useDiscardChangesConfirmation({ + getHasUnsavedChanges: () => isDirty, }); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -65,7 +64,7 @@ function EditUserAvatarContent() { }; const onPress = () => { - isSavingRef.current = true; + notifySaving(); if (imageData.file) { updateAvatar(imageData.file, { @@ -96,7 +95,7 @@ function EditUserAvatarContent() { return; } if (!selected || !avatarCaptureRef.current) { - isSavingRef.current = false; + notifySaving(false); return; } avatarCaptureRef.current @@ -112,7 +111,7 @@ function EditUserAvatarContent() { Navigation.dismissModal(); }) .catch(() => { - isSavingRef.current = false; + notifySaving(false); }); }; diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index f8055d16efdc..920fcc9f616c 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1,7 +1,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import DateUtils from '@libs/DateUtils'; -import {shouldShowBrokenConnectionViolation, shouldShowBrokenConnectionViolationForMultipleTransactions} from '@libs/TransactionUtils'; +import {doesMoneyRequestDraftHaveUserInput, shouldShowBrokenConnectionViolation, shouldShowBrokenConnectionViolationForMultipleTransactions} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -3627,3 +3627,20 @@ describe('TransactionUtils', () => { }); }); }); + +describe('doesMoneyRequestDraftHaveUserInput', () => { + it('returns false for an empty draft', () => { + expect(doesMoneyRequestDraftHaveUserInput(undefined)).toBe(false); + expect(doesMoneyRequestDraftHaveUserInput(generateTransaction())).toBe(false); + }); + + it('returns false when the draft only has empty waypoint placeholders', () => { + const transaction = generateTransaction({comment: {waypoints: {waypoint0: {}, waypoint1: {}}}}); + expect(doesMoneyRequestDraftHaveUserInput(transaction)).toBe(false); + }); + + it('returns true when the user entered a waypoint', () => { + const transaction = generateTransaction({comment: {waypoints: {waypoint0: {address: '350 5th Ave, New York', lat: 40.7484, lng: -73.9857}, waypoint1: {}}}}); + expect(doesMoneyRequestDraftHaveUserInput(transaction)).toBe(true); + }); +}); diff --git a/tests/unit/hooks/useDiscardChangesConfirmation.test.ts b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts new file mode 100644 index 000000000000..2367b9b58a40 --- /dev/null +++ b/tests/unit/hooks/useDiscardChangesConfirmation.test.ts @@ -0,0 +1,328 @@ +import {act, renderHook} from '@testing-library/react-native'; +import {withInternalPopstate} from '@components/Modal/internalPopstateGuard'; +import type {DiscardChangesConfirmation} from '@hooks/useDiscardChangesConfirmation/types'; +import type UseDiscardChangesConfirmationOptions from '@hooks/useDiscardChangesConfirmation/types'; + +type MockBeforeRemoveEvent = { + data: {action: {type: string}}; + defaultPrevented: boolean; + preventDefault: () => void; +}; + +let mockBeforeRemoveCallback: ((e: MockBeforeRemoveEvent) => void) | undefined; +jest.mock('@hooks/useBeforeRemove', () => ({ + __esModule: true, + default: (callback: (e: MockBeforeRemoveEvent) => void) => { + mockBeforeRemoveCallback = callback; + }, +})); + +let mockIsFocused = true; +jest.mock('@react-navigation/native', () => ({ + useIsFocused: () => mockIsFocused, + useFocusEffect: (callback: () => undefined | (() => void)) => { + jest.requireActual<{useEffect: (effect: () => undefined | (() => void), deps: unknown[]) => void}>('react').useEffect(callback, []); + }, +})); + +const mockShowConfirmModal = jest.fn(); +const mockCloseModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal, closeModal: mockCloseModal}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@components/Modal/Global/ModalContext', () => ({ + ModalActions: {CONFIRM: 'CONFIRM', CLOSE: 'CLOSE'}, +})); + +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: {warn: jest.fn()}, +})); + +// Runs its callback immediately so the confirm navigation is synchronous in tests +jest.mock('@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue', () => ({ + __esModule: true, + default: (callback: () => void) => callback(), +})); + +const mockNavigationDispatch = jest.fn(); +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + get current() { + return {dispatch: mockNavigationDispatch, goBack: jest.fn()}; + }, + }, +})); + +type DiscardHookModule = {default: (options: UseDiscardChangesConfirmationOptions) => DiscardChangesConfirmation}; + +// Jest resolves platform extensions native-first, so the web implementation is loaded explicitly +const useDiscardChangesConfirmation = jest.requireActual('@hooks/useDiscardChangesConfirmation/index.ts').default; + +const dispatchPopstate = () => { + act(() => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); +}; + +const createBeforeRemoveEvent = (type: string): MockBeforeRemoveEvent => { + const event: MockBeforeRemoveEvent = { + data: {action: {type}}, + defaultPrevented: false, + preventDefault: () => { + event.defaultPrevented = true; + }, + }; + return event; +}; + +const invokeBeforeRemove = (type: string): MockBeforeRemoveEvent => { + const event = createBeforeRemoveEvent(type); + act(() => { + mockBeforeRemoveCallback?.(event); + }); + return event; +}; + +describe('useDiscardChangesConfirmation (web)', () => { + let historyGoSpy: jest.SpyInstance; + let resolveModal: ((result: {action: string}) => void) | undefined; + + const renderDiscardHook = (getHasUnsavedChanges: () => boolean) => renderHook(() => useDiscardChangesConfirmation({getHasUnsavedChanges})); + + const resolveModalWith = async (action: string) => { + await act(async () => { + resolveModal?.({action}); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockBeforeRemoveCallback = undefined; + mockIsFocused = true; + resolveModal = undefined; + historyGoSpy = jest.spyOn(window.history, 'go').mockImplementation(() => {}); + mockShowConfirmModal.mockImplementation( + () => + new Promise((resolve) => { + resolveModal = resolve; + }), + ); + mockCloseModal.mockImplementation(() => resolveModal?.({action: 'CLOSE'})); + }); + + afterEach(() => { + historyGoSpy.mockRestore(); + }); + + describe('browser back prevented through beforeRemove', () => { + it('prevents the reset, restores the URL once, and shows a single history-inert modal', () => { + renderDiscardHook(() => true); + + const event = invokeBeforeRemove('RESET'); + + expect(event.defaultPrevented).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({shouldHandleNavigationBack: false})); + + dispatchPopstate(); + + expect(historyGoSpy).toHaveBeenCalledTimes(1); + expect(historyGoSpy).toHaveBeenCalledWith(1); + + // The restore round-trip popstate must not start a new flow + dispatchPopstate(); + + expect(historyGoSpy).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('swallows the beforeRemove echo during the restore round-trip without re-blocking', () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + dispatchPopstate(); + + const echo = invokeBeforeRemove('RESET'); + expect(echo.defaultPrevented).toBe(true); + + dispatchPopstate(); + + expect(historyGoSpy).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('dispatches the blocked action when the user confirms discarding', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + dispatchPopstate(); + dispatchPopstate(); + await resolveModalWith('CONFIRM'); + + expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'RESET'}); + expect(historyGoSpy).toHaveBeenCalledTimes(1); + }); + + it('lets the confirmed re-dispatch through without re-blocking it', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + dispatchPopstate(); + dispatchPopstate(); + + let redeliveredEvent: MockBeforeRemoveEvent | undefined; + mockNavigationDispatch.mockImplementation(() => { + redeliveredEvent = createBeforeRemoveEvent('RESET'); + mockBeforeRemoveCallback?.(redeliveredEvent); + }); + + await resolveModalWith('CONFIRM'); + + expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'RESET'}); + expect(redeliveredEvent?.defaultPrevented).toBe(false); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('starts a fresh flow after cancelling', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + dispatchPopstate(); + dispatchPopstate(); + await resolveModalWith('CLOSE'); + + const secondEvent = invokeBeforeRemove('RESET'); + expect(secondEvent.defaultPrevented).toBe(true); + + dispatchPopstate(); + + expect(historyGoSpy).toHaveBeenCalledTimes(2); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); + }); + + it('cancelling a reset blocked without a popstate (programmatic reset) leaves later popstate events untouched', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + await resolveModalWith('CLOSE'); + + dispatchPopstate(); + + expect(historyGoSpy).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('dismisses the prompt as Cancel and restores the URL on a browser back while it is open', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + dispatchPopstate(); + dispatchPopstate(); + + // e.g. a browser back while the modal is open + const whileOpen = invokeBeforeRemove('RESET'); + expect(whileOpen.defaultPrevented).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + + dispatchPopstate(); + + expect(historyGoSpy).toHaveBeenCalledTimes(2); + expect(mockCloseModal).toHaveBeenCalledTimes(1); + + await act(async () => { + await Promise.resolve(); + }); + expect(mockNavigationDispatch).not.toHaveBeenCalled(); + }); + }); + + describe('in-app navigation', () => { + it('shows the modal without touching browser history for non-RESET actions', () => { + renderDiscardHook(() => true); + + const event = invokeBeforeRemove('POP'); + + expect(event.defaultPrevented).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(historyGoSpy).not.toHaveBeenCalled(); + }); + + it('allows navigation when there are no unsaved changes', () => { + renderDiscardHook(() => false); + + const event = invokeBeforeRemove('RESET'); + + expect(event.defaultPrevented).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('allows navigation when the screen is not focused, even with a dirty predicate', () => { + mockIsFocused = false; + renderDiscardHook(() => true); + + const event = invokeBeforeRemove('RESET'); + + expect(event.defaultPrevented).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('suppresses the prompt while a save is in progress, and re-arms when notified it ended', () => { + const {result} = renderDiscardHook(() => true); + + act(() => result.current.notifySaving()); + const duringSave = invokeBeforeRemove('RESET'); + + expect(duringSave.defaultPrevented).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + act(() => result.current.notifySaving(false)); + const afterSave = invokeBeforeRemove('RESET'); + + expect(afterSave.defaultPrevented).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('popstate guards', () => { + it('does nothing on a popstate without a preceding prevented reset', () => { + renderDiscardHook(() => true); + + dispatchPopstate(); + + expect(historyGoSpy).not.toHaveBeenCalled(); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('ignores internal popstate events from modal history cleanup', () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('RESET'); + + act(() => { + withInternalPopstate(() => { + window.dispatchEvent(new PopStateEvent('popstate')); + }); + }); + + // The internal popstate must not consume the pending URL restore + expect(historyGoSpy).not.toHaveBeenCalled(); + + dispatchPopstate(); + + expect(historyGoSpy).toHaveBeenCalledTimes(1); + expect(historyGoSpy).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts new file mode 100644 index 000000000000..d9fec4619e87 --- /dev/null +++ b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts @@ -0,0 +1,253 @@ +import {act, renderHook} from '@testing-library/react-native'; +import {BackHandler} from 'react-native'; +import type {DiscardChangesConfirmation} from '@hooks/useDiscardChangesConfirmation/types'; +import type UseDiscardChangesConfirmationOptions from '@hooks/useDiscardChangesConfirmation/types'; + +type MockBeforeRemoveEvent = {data: {action: {type: string}}}; + +let mockPreventRemoveFlag: boolean | undefined; +let mockPreventRemoveCallback: ((e: MockBeforeRemoveEvent) => void) | undefined; +let mockIsFocused = true; +jest.mock('@react-navigation/native', () => ({ + usePreventRemove: (flag: boolean, callback: (e: MockBeforeRemoveEvent) => void) => { + mockPreventRemoveFlag = flag; + mockPreventRemoveCallback = callback; + }, + useIsFocused: () => mockIsFocused, + // 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]); + }, +})); + +const mockShowConfirmModal = jest.fn(); +jest.mock('@hooks/useConfirmModal', () => ({ + __esModule: true, + default: () => ({showConfirmModal: mockShowConfirmModal}), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({translate: (key: string) => key}), +})); + +jest.mock('@components/Modal/Global/ModalContext', () => ({ + ModalActions: {CONFIRM: 'CONFIRM', CLOSE: 'CLOSE'}, +})); + +jest.mock('@libs/Log', () => ({ + __esModule: true, + default: {warn: jest.fn()}, +})); + +const mockNavigationDispatch = jest.fn(); +const mockNavigationGoBack = jest.fn(); +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: { + get current() { + return {dispatch: mockNavigationDispatch, goBack: mockNavigationGoBack}; + }, + }, +})); + +type DiscardHookModule = {default: (options: UseDiscardChangesConfirmationOptions) => DiscardChangesConfirmation}; + +const useDiscardChangesConfirmation = jest.requireActual('@hooks/useDiscardChangesConfirmation/index.native.ts').default; + +describe('useDiscardChangesConfirmation (native)', () => { + let backHandlerSpy: jest.SpyInstance; + let hardwareBackCallback: (() => boolean | null | undefined) | undefined; + const removeSubscription = jest.fn(); + let resolveModal: ((result: {action: string}) => void) | undefined; + + const renderDiscardHook = (getHasUnsavedChanges: () => boolean) => renderHook(() => useDiscardChangesConfirmation({getHasUnsavedChanges})); + + const pressHardwareBack = (): boolean | null | undefined => { + let consumed: boolean | null | undefined; + act(() => { + consumed = hardwareBackCallback?.(); + }); + return consumed; + }; + + const resolveModalWith = async (action: string) => { + await act(async () => { + resolveModal?.({action}); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockPreventRemoveFlag = undefined; + mockPreventRemoveCallback = undefined; + mockIsFocused = true; + hardwareBackCallback = undefined; + resolveModal = undefined; + backHandlerSpy = jest.spyOn(BackHandler, 'addEventListener').mockImplementation((event, handler) => { + hardwareBackCallback = handler; + return {remove: removeSubscription}; + }); + mockShowConfirmModal.mockImplementation( + () => + new Promise((resolve) => { + resolveModal = resolve; + }), + ); + }); + + afterEach(() => { + backHandlerSpy.mockRestore(); + }); + + describe('hardware back (tab-switch case: no removal, usePreventRemove blind)', () => { + it('consumes the back press and shows the modal when the form is dirty', () => { + renderDiscardHook(() => true); + + expect(pressHardwareBack()).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(mockNavigationGoBack).not.toHaveBeenCalled(); + }); + + it('lets the back press through when the form is clean', () => { + renderDiscardHook(() => false); + + expect(pressHardwareBack()).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('lets the back press through after notifySaving, and prompts again once the save ends', () => { + const {result} = renderDiscardHook(() => true); + + act(() => result.current.notifySaving()); + expect(pressHardwareBack()).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + + act(() => result.current.notifySaving(false)); + expect(pressHardwareBack()).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('lets the back press through when the screen is not focused, even with a dirty form', () => { + mockIsFocused = false; + renderDiscardHook(() => true); + + expect(pressHardwareBack()).toBe(false); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('swallows back presses while the modal is open without stacking a second modal', () => { + renderDiscardHook(() => true); + + pressHardwareBack(); + + expect(pressHardwareBack()).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('replays the back with goBack on confirm and keeps prevention armed', async () => { + renderDiscardHook(() => true); + + pressHardwareBack(); + await resolveModalWith('CONFIRM'); + + expect(mockNavigationGoBack).toHaveBeenCalledTimes(1); + expect(mockNavigationDispatch).not.toHaveBeenCalled(); + expect(mockPreventRemoveFlag).toBe(true); + }); + + it('re-dispatches a beforeRemove fired during the goBack replay instead of re-prompting', async () => { + renderDiscardHook(() => true); + + pressHardwareBack(); + + // On the initial tab the replayed goBack pops the screen, which fires beforeRemove synchronously + mockNavigationGoBack.mockImplementationOnce(() => { + mockPreventRemoveCallback?.({data: {action: {type: 'POP'}}}); + }); + + await resolveModalWith('CONFIRM'); + + expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'POP'}); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('stays put on cancel and prompts again on the next back press', async () => { + renderDiscardHook(() => true); + const onCancel = jest.fn(); + renderHook(() => useDiscardChangesConfirmation({getHasUnsavedChanges: () => true, onCancel})); + + pressHardwareBack(); + await resolveModalWith('CLOSE'); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(mockNavigationGoBack).not.toHaveBeenCalled(); + expect(mockNavigationDispatch).not.toHaveBeenCalled(); + + expect(pressHardwareBack()).toBe(true); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); + }); + + it('removes the hardware back subscription on unmount', () => { + const {unmount} = renderDiscardHook(() => true); + + unmount(); + + expect(removeSubscription).toHaveBeenCalled(); + }); + }); + + describe('usePreventRemove (removal case: header back, in-app pop)', () => { + const invokeBeforeRemove = (type: string) => { + act(() => { + mockPreventRemoveCallback?.({data: {action: {type}}}); + }); + }; + + it('shows the modal and dispatches the blocked action on confirm', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('POP'); + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + + await resolveModalWith('CONFIRM'); + + expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'POP'}); + expect(mockNavigationGoBack).not.toHaveBeenCalled(); + }); + + it('allows and replays the action immediately when the form is clean', () => { + renderDiscardHook(() => false); + + invokeBeforeRemove('POP'); + + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(mockNavigationDispatch).toHaveBeenCalledWith({type: 'POP'}); + }); + + it('ignores beforeRemove while the modal is already open', () => { + renderDiscardHook(() => true); + + pressHardwareBack(); + invokeBeforeRemove('POP'); + + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + }); + + it('clears the blocked action on cancel so a later hardware-back confirm uses goBack', async () => { + renderDiscardHook(() => true); + + invokeBeforeRemove('POP'); + await resolveModalWith('CLOSE'); + + pressHardwareBack(); + await resolveModalWith('CONFIRM'); + + expect(mockNavigationDispatch).not.toHaveBeenCalled(); + expect(mockNavigationGoBack).toHaveBeenCalledTimes(1); + }); + }); +});