diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index d752872e42ef..1e56a073921e 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -9,7 +9,7 @@ import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext' import type {NavigationAction} from '@react-navigation/native'; import {useFocusEffect, useIsFocused, usePreventRemove, useRoute} from '@react-navigation/native'; -import {useRef} from 'react'; +import {useCallback, useEffect, useRef, useState} from 'react'; import {BackHandler} from 'react-native'; import type {DiscardChangesConfirmation} from './types'; @@ -40,6 +40,17 @@ function useDiscardChangesConfirmation({ }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); + // `usePreventRemove` reads this during render, so the signal must be state, never a ref (React Compiler) + const [shouldPreventRemove, setShouldPreventRemove] = useState(false); + // Deferred past the commit so ref-backed inputs are read after their child state settles, not one event behind + const recheckUnsavedChanges = useCallback(() => { + setTimeout(() => { + setShouldPreventRemove(isFocused && !isSavingRef.current && getHasUnsavedChanges()); + }, 0); + }, [isFocused, getHasUnsavedChanges]); + // Runs every commit since dirtiness often lives in refs; screens that never re-render on input call `recheckUnsavedChanges` themselves + useEffect(recheckUnsavedChanges); + useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel); const showDiscardModal = (blockedAction?: NavigationAction) => { @@ -70,7 +81,7 @@ function useDiscardChangesConfirmation({ }); }; - usePreventRemove(true, ({data}: {data: {action: NavigationAction}}) => { + usePreventRemove(shouldPreventRemove, ({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); @@ -102,7 +113,7 @@ function useDiscardChangesConfirmation({ isSavingRef.current = shouldSuppress; }; - return {suppressDiscardPrompt}; + return {suppressDiscardPrompt, recheckUnsavedChanges}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index eb5409f63715..602977ac7cc6 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -26,6 +26,9 @@ import runDiscardConfirmation from './runDiscardConfirmation'; */ type RestoreState = {phase: 'idle'} | {phase: 'awaitingRestore'; dismissModalOnRestore: boolean} | {phase: 'restoring'}; +// Web reads dirtiness at navigation time in `useBeforeRemove`; a stable no-op just keeps the shared return type +function recheckUnsavedChangesNoop() {} + function useDiscardChangesConfirmation({ getHasUnsavedChanges, onCancel, @@ -166,7 +169,7 @@ function useDiscardChangesConfirmation({ isSavingRef.current = shouldSuppress; }; - return {suppressDiscardPrompt}; + return {suppressDiscardPrompt, recheckUnsavedChanges: recheckUnsavedChangesNoop}; } export default useDiscardChangesConfirmation; diff --git a/src/hooks/useDiscardChangesConfirmation/types.ts b/src/hooks/useDiscardChangesConfirmation/types.ts index fa42ee5b0d13..ce60d4287e17 100644 --- a/src/hooks/useDiscardChangesConfirmation/types.ts +++ b/src/hooks/useDiscardChangesConfirmation/types.ts @@ -10,6 +10,9 @@ type UseDiscardChangesConfirmationOptions = { type DiscardChangesConfirmation = { /** Suppress the discard prompt during an intentional navigation (a save, or a redirect such as the billing restriction). Pass `false` to clear it if that navigation aborts without leaving. */ suppressDiscardPrompt: (shouldSuppress?: boolean) => void; + + /** Force the hook to re-evaluate unsaved changes. Ref-based input screens call this after the typed value changes so the native removal guard re-arms. */ + recheckUnsavedChanges: () => void; }; export default UseDiscardChangesConfirmationOptions; diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index c74ff9a80257..bcb1ac95ab2e 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -106,6 +106,7 @@ function MoneyRequestAmountForm({ policyID = '', onCurrencyButtonPress, onSubmitButtonPress, + onAmountChange, selectedTab = CONST.TAB_REQUEST.MANUAL, shouldKeepUserInput = false, chatReportID, @@ -156,7 +157,9 @@ function MoneyRequestAmountForm({ const toggleNegative = useCallback(() => { setIsNegative(!isNegative); - }, [isNegative]); + // The sign flip bypasses the input's change handler, so notify the parent like a keystroke would + onAmountChange?.(moneyRequestAmountInputRef.current?.getNumber() ?? ''); + }, [isNegative, onAmountChange]); const clearNegative = useCallback(() => { setIsNegative(false); @@ -299,7 +302,8 @@ function MoneyRequestAmountForm({ isCurrencyPressable={isCurrencyPressable} onCurrencyButtonPress={onCurrencyButtonPress} onFormatAmount={onFormatAmount} - onAmountChange={() => { + onAmountChange={(newAmount) => { + onAmountChange?.(newAmount); if (!formError) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 8b4043df52f4..852ae99c1372 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -131,7 +131,7 @@ function IOURequestStepAmount({ const decimals = getCurrencyDecimals(selectedCurrency || CONST.CURRENCY.USD); const isAmountCreateEntry = !backTo && !isEditing; - const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt, recheckUnsavedChanges} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => getAmountHasUnsavedChanges({ typedAmount: amountFormRef.current?.getNumber() ?? '', @@ -313,6 +313,7 @@ function IOURequestStepAmount({ shouldKeepUserInput={transaction?.shouldShowOriginalAmount} onCurrencyButtonPress={showCurrencyPicker} onSubmitButtonPress={handleSubmit} + onAmountChange={recheckUnsavedChanges} allowFlippingAmount={!isSplitBill && allowNegative} selectedTab={iouRequestType as SelectedTabRequest} chatReportID={reportID} diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 4b23695456d4..602193449296 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -199,7 +199,7 @@ function IOURequestStepDistance({ [distanceInMeters, distanceUnit], ); - const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt, recheckUnsavedChanges} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { // Manual distance sits in `manualNumberFormRef` until Save — gate on the mounted ref so a cleared (empty) value still counts as dirty against a committed distance. const manualForm = manualNumberFormRef.current; @@ -708,11 +708,13 @@ function IOURequestStepDistance({ const handleManualInputChange = useCallback(() => { isManuallyEditing.current = true; + // Typed distance lives in a child ref and typing never re-renders this screen, so re-arm the guard here + recheckUnsavedChanges(); if (!manualFormError) { return; } setManualFormError(''); - }, [manualFormError]); + }, [manualFormError, recheckUnsavedChanges]); const errorState = useMemo( () => ({ diff --git a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx index 22c56fb7fd34..7e8ae2fc31db 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceManual.tsx @@ -159,7 +159,7 @@ function IOURequestStepDistanceManual({ const distanceInMeters = getDistanceInMeters(transaction, transaction?.comment?.customUnit?.distanceUnit ? transaction.comment.customUnit.distanceUnit : unit); const distance = typeof transaction?.comment?.customUnit?.quantity === 'number' ? roundToTwoDecimalPlaces(DistanceRequestUtils.convertDistanceUnit(distanceInMeters, unit)) : undefined; - const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt, recheckUnsavedChanges} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedDistance = numberFormRef.current?.getNumber() ?? ''; return getStringFieldHasUnsavedChanges(typedDistance, distance?.toString() ?? '', isCreatingNewRequest); @@ -338,6 +338,7 @@ function IOURequestStepDistanceManual({ value={distance?.toString()} shouldUseDynamicFontSize onInputChange={() => { + recheckUnsavedChanges(); if (!formError) { return; } diff --git a/src/pages/iou/request/step/IOURequestStepHours.tsx b/src/pages/iou/request/step/IOURequestStepHours.tsx index 6c5a190b28bd..8ef0e5f43ad7 100644 --- a/src/pages/iou/request/step/IOURequestStepHours.tsx +++ b/src/pages/iou/request/step/IOURequestStepHours.tsx @@ -89,7 +89,7 @@ function IOURequestStepHours({ moneyRequestTimeInputRef.current?.updateNumber(`${transaction?.comment?.units?.count ?? ''}`); }, [selectedTab, transaction?.comment?.units?.count]); - const {suppressDiscardPrompt} = useDiscardChangesConfirmation({ + const {suppressDiscardPrompt, recheckUnsavedChanges} = useDiscardChangesConfirmation({ getHasUnsavedChanges: () => { const typedCount = moneyRequestTimeInputRef.current?.getNumber() ?? ''; return getStringFieldHasUnsavedChanges(typedCount, `${transaction?.comment?.units?.count ?? ''}`, isEmbeddedInStartPage); @@ -177,6 +177,7 @@ function IOURequestStepHours({ errorText={formError} touchableInputWrapperStyle={styles.heightUndefined} onInputChange={() => { + recheckUnsavedChanges(); if (!formError) { return; } diff --git a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts index c4946f3af59f..7c06397c6cc5 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts @@ -84,6 +84,15 @@ describe('useDiscardChangesConfirmation (native)', () => { }); }; + // The armed flag updates a tick after the commit (setTimeout 0), so flush that timer before asserting on it + const flushDeferredRecheck = async () => { + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + }; + beforeEach(() => { jest.clearAllMocks(); mockPreventRemoveFlag = undefined; @@ -157,12 +166,26 @@ describe('useDiscardChangesConfirmation (native)', () => { pressHardwareBack(); await resolveModalWith('CONFIRM'); + await flushDeferredRecheck(); expect(mockNavigationGoBack).toHaveBeenCalledTimes(1); expect(mockNavigationDispatch).not.toHaveBeenCalled(); expect(mockPreventRemoveFlag).toBe(true); }); + it('arms prevention once a recheck sees unsaved changes', async () => { + let hasChanges = false; + const {result} = renderDiscardHook(() => hasChanges); + + await flushDeferredRecheck(); + expect(mockPreventRemoveFlag).toBe(false); + + hasChanges = true; + act(() => result.current.recheckUnsavedChanges()); + await flushDeferredRecheck(); + expect(mockPreventRemoveFlag).toBe(true); + }); + it('re-dispatches a beforeRemove fired during the goBack replay instead of re-prompting', async () => { renderDiscardHook(() => true);