From c9add6915c0cba7c2ca1f26474f129efde29edcc Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 25 Jul 2026 01:31:23 +0100 Subject: [PATCH 1/3] Stop iOS discard guard from blinking on a clean swipe-back usePreventRemove was hard-coded to true, so iOS set preventNativeDismiss even with nothing to discard: the edge-swipe was cancelled and snapped back before JS popped the screen. Drive the guard from a shouldPreventRemove state that only reflects real unsaved changes, recomputed in a callback/effect (never reading refs during render, so React Compiler still compiles the hook). Ref-based input screens call recheckUnsavedChanges after each keystroke so the guard re-arms. Fixes #94904. --- .../useDiscardChangesConfirmation/index.native.ts | 15 ++++++++++++--- src/hooks/useDiscardChangesConfirmation/index.ts | 7 +++++-- src/hooks/useDiscardChangesConfirmation/types.ts | 3 +++ src/pages/iou/MoneyRequestAmountForm.tsx | 4 +++- .../iou/request/step/IOURequestStepAmount.tsx | 3 ++- .../request/step/IOURequestStepDistanceManual.tsx | 3 ++- .../iou/request/step/IOURequestStepHours.tsx | 3 ++- 7 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index d752872e42ef..e8fdcf5da6cc 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,15 @@ function useDiscardChangesConfirmation({ }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); + // `usePreventRemove` reads this during render, so the block signal must be state (never a ref) to stay React Compiler-safe. + // The save ref and the screen's dirtiness callback are read inside this callback/effect, not during render. + // Ref-based input screens (which only re-render to clear a form error) call `recheckUnsavedChanges` after each keystroke. + const [shouldPreventRemove, setShouldPreventRemove] = useState(false); + const recheckUnsavedChanges = useCallback(() => { + setShouldPreventRemove(isFocused && !isSavingRef.current && getHasUnsavedChanges()); + }, [isFocused, getHasUnsavedChanges]); + useEffect(recheckUnsavedChanges, [recheckUnsavedChanges]); + useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel); const showDiscardModal = (blockedAction?: NavigationAction) => { @@ -70,7 +79,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 +111,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..c8a0e62da38a 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -11,7 +11,7 @@ import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext' import type {NavigationAction} from '@react-navigation/native'; import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native'; -import {useEffect, useRef} from 'react'; +import {useEffect, useReducer, useRef} from 'react'; import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; @@ -52,6 +52,9 @@ function useDiscardChangesConfirmation({ const isDiscardModalOpen = useRef(false); const restoreState = useRef({phase: 'idle'}); + // Kept symmetric with the native hook: web reads `hasUnsavedChanges()` lazily in `useBeforeRemove`, so this is a harmless no-op re-render trigger here. + const [, recheckUnsavedChanges] = useReducer((count: number) => count + 1, 0); + const navigateBack = () => { if (!blockedNavigationAction.current) { return; @@ -166,7 +169,7 @@ function useDiscardChangesConfirmation({ isSavingRef.current = shouldSuppress; }; - return {suppressDiscardPrompt}; + return {suppressDiscardPrompt, recheckUnsavedChanges}; } 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..9107479d7eee 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, @@ -299,7 +300,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/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; } From 15089192c947c81c76106ed2d06ffb0210fa121d Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 25 Jul 2026 03:11:29 +0100 Subject: [PATCH 2/3] Close discard-guard staleness gaps for ref-backed inputs The preventRemove state could read one input event behind (or never arm) for screens whose typed value lives in a child input ref: - Defer the recompute past the commit so it reads the child's settled state instead of the previous render's value (single digit or paste then back would have dismissed with no prompt). - Re-evaluate after every commit instead of only when the memoized dirtiness callback changes identity, which covers screens like the odometer step whose refs move together with state the compiler does not track through the callback. - Wire recheckUnsavedChanges into the distance step's manual tab, which never re-renders on typing. - Notify the parent from the amount sign flip, which bypassed the input's change handler entirely. - Replace the web hook's counter reducer with a stable no-op so amount, hours, and manual-distance screens stop re-rendering per keystroke. - Pin the recheck arming behavior in the native hook tests. --- .../index.native.ts | 12 +++++++--- .../useDiscardChangesConfirmation/index.ts | 11 +++++---- src/pages/iou/MoneyRequestAmountForm.tsx | 5 +++- .../request/step/IOURequestStepDistance.tsx | 7 ++++-- ...seDiscardChangesConfirmationNative.test.ts | 24 +++++++++++++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index e8fdcf5da6cc..694192375f7a 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -42,12 +42,18 @@ function useDiscardChangesConfirmation({ // `usePreventRemove` reads this during render, so the block signal must be state (never a ref) to stay React Compiler-safe. // The save ref and the screen's dirtiness callback are read inside this callback/effect, not during render. - // Ref-based input screens (which only re-render to clear a form error) call `recheckUnsavedChanges` after each keystroke. const [shouldPreventRemove, setShouldPreventRemove] = useState(false); + // The recompute is deferred past the commit: ref-backed inputs update their child's state in the same event that + // calls this, so a synchronous read would see the previous render's value and lag one input event behind. const recheckUnsavedChanges = useCallback(() => { - setShouldPreventRemove(isFocused && !isSavingRef.current && getHasUnsavedChanges()); + setTimeout(() => { + setShouldPreventRemove(isFocused && !isSavingRef.current && getHasUnsavedChanges()); + }, 0); }, [isFocused, getHasUnsavedChanges]); - useEffect(recheckUnsavedChanges, [recheckUnsavedChanges]); + // No dependency array on purpose: dirtiness often lives in refs the compiler cannot track through + // `getHasUnsavedChanges`, so re-evaluate after every commit (the set bails out when the boolean is unchanged). + // Screens that go dirty without re-rendering at all call `recheckUnsavedChanges` from their change handlers. + useEffect(recheckUnsavedChanges); useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel); diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index c8a0e62da38a..31f23e9c3a2d 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -11,7 +11,7 @@ import {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext' import type {NavigationAction} from '@react-navigation/native'; import {useFocusEffect, useIsFocused, useRoute} from '@react-navigation/native'; -import {useEffect, useReducer, useRef} from 'react'; +import {useEffect, useRef} from 'react'; import type {DiscardChangesConfirmation} from './types'; import type UseDiscardChangesConfirmationOptions from './types'; @@ -26,6 +26,10 @@ import runDiscardConfirmation from './runDiscardConfirmation'; */ type RestoreState = {phase: 'idle'} | {phase: 'awaitingRestore'; dismissModalOnRestore: boolean} | {phase: 'restoring'}; +// Web reads `hasUnsavedChanges()` at navigation time in `useBeforeRemove`, so there is no cached flag to recompute. +// A stable module-level no-op keeps the shared return type without re-rendering consumers on every input event. +function recheckUnsavedChangesNoop() {} + function useDiscardChangesConfirmation({ getHasUnsavedChanges, onCancel, @@ -52,9 +56,6 @@ function useDiscardChangesConfirmation({ const isDiscardModalOpen = useRef(false); const restoreState = useRef({phase: 'idle'}); - // Kept symmetric with the native hook: web reads `hasUnsavedChanges()` lazily in `useBeforeRemove`, so this is a harmless no-op re-render trigger here. - const [, recheckUnsavedChanges] = useReducer((count: number) => count + 1, 0); - const navigateBack = () => { if (!blockedNavigationAction.current) { return; @@ -169,7 +170,7 @@ function useDiscardChangesConfirmation({ isSavingRef.current = shouldSuppress; }; - return {suppressDiscardPrompt, recheckUnsavedChanges}; + return {suppressDiscardPrompt, recheckUnsavedChanges: recheckUnsavedChangesNoop}; } export default useDiscardChangesConfirmation; diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 9107479d7eee..558e24733420 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -157,7 +157,10 @@ function MoneyRequestAmountForm({ const toggleNegative = useCallback(() => { setIsNegative(!isNegative); - }, [isNegative]); + // The sign flip changes the effective amount without going through the input's change handler, + // so notify the parent the same way a keystroke would (e.g. to re-arm the discard guard). + onAmountChange?.(moneyRequestAmountInputRef.current?.getNumber() ?? ''); + }, [isNegative, onAmountChange]); const clearNegative = useCallback(() => { setIsNegative(false); diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 4b23695456d4..48550721c66e 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,14 @@ function IOURequestStepDistance({ const handleManualInputChange = useCallback(() => { isManuallyEditing.current = true; + // The typed distance lives in a child input ref and this screen does not re-render on typing, + // so the discard guard has to be re-armed here. + recheckUnsavedChanges(); if (!manualFormError) { return; } setManualFormError(''); - }, [manualFormError]); + }, [manualFormError, recheckUnsavedChanges]); const errorState = useMemo( () => ({ diff --git a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts index c4946f3af59f..74b8eb05545b 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts @@ -84,6 +84,16 @@ describe('useDiscardChangesConfirmation (native)', () => { }); }; + // The hook defers its preventRemove recompute past the commit (setTimeout 0), so tests that + // assert on the armed flag must let that timer fire first. + const flushDeferredRecheck = async () => { + await act(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + }); + }; + beforeEach(() => { jest.clearAllMocks(); mockPreventRemoveFlag = undefined; @@ -157,12 +167,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); From 938fec7d2204c1bbc7a4df722ccc5e86f313b69b Mon Sep 17 00:00:00 2001 From: MobileMage Date: Sat, 25 Jul 2026 13:13:34 +0100 Subject: [PATCH 3/3] Tighten comments to match the surrounding style --- .../useDiscardChangesConfirmation/index.native.ts | 10 +++------- src/hooks/useDiscardChangesConfirmation/index.ts | 3 +-- src/pages/iou/MoneyRequestAmountForm.tsx | 3 +-- src/pages/iou/request/step/IOURequestStepDistance.tsx | 3 +-- .../hooks/useDiscardChangesConfirmationNative.test.ts | 3 +-- 5 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/hooks/useDiscardChangesConfirmation/index.native.ts b/src/hooks/useDiscardChangesConfirmation/index.native.ts index 694192375f7a..1e56a073921e 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.native.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.native.ts @@ -40,19 +40,15 @@ function useDiscardChangesConfirmation({ }); const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges(); - // `usePreventRemove` reads this during render, so the block signal must be state (never a ref) to stay React Compiler-safe. - // The save ref and the screen's dirtiness callback are read inside this callback/effect, not during render. + // `usePreventRemove` reads this during render, so the signal must be state, never a ref (React Compiler) const [shouldPreventRemove, setShouldPreventRemove] = useState(false); - // The recompute is deferred past the commit: ref-backed inputs update their child's state in the same event that - // calls this, so a synchronous read would see the previous render's value and lag one input event behind. + // 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]); - // No dependency array on purpose: dirtiness often lives in refs the compiler cannot track through - // `getHasUnsavedChanges`, so re-evaluate after every commit (the set bails out when the boolean is unchanged). - // Screens that go dirty without re-rendering at all call `recheckUnsavedChanges` from their change handlers. + // 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); diff --git a/src/hooks/useDiscardChangesConfirmation/index.ts b/src/hooks/useDiscardChangesConfirmation/index.ts index 31f23e9c3a2d..602977ac7cc6 100644 --- a/src/hooks/useDiscardChangesConfirmation/index.ts +++ b/src/hooks/useDiscardChangesConfirmation/index.ts @@ -26,8 +26,7 @@ import runDiscardConfirmation from './runDiscardConfirmation'; */ type RestoreState = {phase: 'idle'} | {phase: 'awaitingRestore'; dismissModalOnRestore: boolean} | {phase: 'restoring'}; -// Web reads `hasUnsavedChanges()` at navigation time in `useBeforeRemove`, so there is no cached flag to recompute. -// A stable module-level no-op keeps the shared return type without re-rendering consumers on every input event. +// Web reads dirtiness at navigation time in `useBeforeRemove`; a stable no-op just keeps the shared return type function recheckUnsavedChangesNoop() {} function useDiscardChangesConfirmation({ diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index 558e24733420..bcb1ac95ab2e 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -157,8 +157,7 @@ function MoneyRequestAmountForm({ const toggleNegative = useCallback(() => { setIsNegative(!isNegative); - // The sign flip changes the effective amount without going through the input's change handler, - // so notify the parent the same way a keystroke would (e.g. to re-arm the discard guard). + // The sign flip bypasses the input's change handler, so notify the parent like a keystroke would onAmountChange?.(moneyRequestAmountInputRef.current?.getNumber() ?? ''); }, [isNegative, onAmountChange]); diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 48550721c66e..602193449296 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -708,8 +708,7 @@ function IOURequestStepDistance({ const handleManualInputChange = useCallback(() => { isManuallyEditing.current = true; - // The typed distance lives in a child input ref and this screen does not re-render on typing, - // so the discard guard has to be re-armed here. + // Typed distance lives in a child ref and typing never re-renders this screen, so re-arm the guard here recheckUnsavedChanges(); if (!manualFormError) { return; diff --git a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts index 74b8eb05545b..7c06397c6cc5 100644 --- a/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts +++ b/tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts @@ -84,8 +84,7 @@ describe('useDiscardChangesConfirmation (native)', () => { }); }; - // The hook defers its preventRemove recompute past the commit (setTimeout 0), so tests that - // assert on the armed flag must let that timer fire first. + // 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) => {