Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions src/hooks/useDiscardChangesConfirmation/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -102,7 +113,7 @@ function useDiscardChangesConfirmation({
isSavingRef.current = shouldSuppress;
};

return {suppressDiscardPrompt};
return {suppressDiscardPrompt, recheckUnsavedChanges};
}

export default useDiscardChangesConfirmation;
5 changes: 4 additions & 1 deletion src/hooks/useDiscardChangesConfirmation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -166,7 +169,7 @@ function useDiscardChangesConfirmation({
isSavingRef.current = shouldSuppress;
};

return {suppressDiscardPrompt};
return {suppressDiscardPrompt, recheckUnsavedChanges: recheckUnsavedChangesNoop};
}

export default useDiscardChangesConfirmation;
3 changes: 3 additions & 0 deletions src/hooks/useDiscardChangesConfirmation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/pages/iou/MoneyRequestAmountForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function MoneyRequestAmountForm({
policyID = '',
onCurrencyButtonPress,
onSubmitButtonPress,
onAmountChange,
selectedTab = CONST.TAB_REQUEST.MANUAL,
shouldKeepUserInput = false,
chatReportID,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -299,7 +302,8 @@ function MoneyRequestAmountForm({
isCurrencyPressable={isCurrencyPressable}
onCurrencyButtonPress={onCurrencyButtonPress}
onFormatAmount={onFormatAmount}
onAmountChange={() => {
onAmountChange={(newAmount) => {
onAmountChange?.(newAmount);
if (!formError) {
return;
}
Expand Down
3 changes: 2 additions & 1 deletion src/pages/iou/request/step/IOURequestStepAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() ?? '',
Expand Down Expand Up @@ -313,6 +313,7 @@ function IOURequestStepAmount({
shouldKeepUserInput={transaction?.shouldShowOriginalAmount}
onCurrencyButtonPress={showCurrencyPicker}
onSubmitButtonPress={handleSubmit}
onAmountChange={recheckUnsavedChanges}
allowFlippingAmount={!isSplitBill && allowNegative}
selectedTab={iouRequestType as SelectedTabRequest}
chatReportID={reportID}
Expand Down
6 changes: 4 additions & 2 deletions src/pages/iou/request/step/IOURequestStepDistance.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -338,6 +338,7 @@ function IOURequestStepDistanceManual({
value={distance?.toString()}
shouldUseDynamicFontSize
onInputChange={() => {
recheckUnsavedChanges();
if (!formError) {
return;
}
Expand Down
3 changes: 2 additions & 1 deletion src/pages/iou/request/step/IOURequestStepHours.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -177,6 +177,7 @@ function IOURequestStepHours({
errorText={formError}
touchableInputWrapperStyle={styles.heightUndefined}
onInputChange={() => {
recheckUnsavedChanges();
if (!formError) {
return;
}
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/hooks/useDiscardChangesConfirmationNative.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down
Loading