Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e4ab982
fix: use the committed baseline for money-request discard detection
TaduJR Jun 30, 2026
d39bf4e
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 30, 2026
d694324
fix: treat a typed zero as unsaved input on money-request create steps
TaduJR Jun 30, 2026
3f44c54
fix: show the discard modal when editing waypoints on a distance expense
TaduJR Jun 30, 2026
db81b47
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 30, 2026
cda12bd
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 1, 2026
30ed3c5
fix: default native tab backBehavior to 'none' so back leaves tabbed …
TaduJR Jul 1, 2026
63890c9
refactor: simplify discard-confirmation internals and money-request p…
TaduJR Jul 1, 2026
8e245f0
fix: let the confirmed discard replay bypass the history-restore swallow
TaduJR Jul 1, 2026
91abe19
fix: don't prompt discard on a clean distance edit when the waypoint …
TaduJR Jul 1, 2026
220acc5
fix: suppress the discard prompt on distance edit-save navigation
TaduJR Jul 1, 2026
38c5cea
fix: flag manual distance edits in the distance discard check
TaduJR Jul 1, 2026
4120f35
fix: treat a cleared manual distance as a discard-worthy edit
TaduJR Jul 1, 2026
e1641ce
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 1, 2026
2c2beea
refactor: make IOURequestStepDistanceMap honestly create-only
TaduJR Jul 1, 2026
6be4c83
test: use a cspell-clean waypoint address in MoneyRequestUtilsTest
TaduJR Jul 1, 2026
06a5ebe
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 1, 2026
dac0873
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 5, 2026
9eaa346
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 9, 2026
b2a562c
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 10, 2026
ed77427
refactor: use the create-presence check directly in the create-only d…
TaduJR Jul 10, 2026
bdfa728
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 11, 2026
5a0e627
fix: flag a cleared amount field as dirty when the create draft alrea…
TaduJR Jul 11, 2026
f91d155
fix: flag cleared create fields as dirty when the draft already holds…
TaduJR Jul 11, 2026
a4d5bf0
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jul 13, 2026
205a172
style: make the money-request predicate JSDocs multiline
TaduJR Jul 13, 2026
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
22 changes: 8 additions & 14 deletions src/hooks/useDiscardChangesConfirmation/index.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext';

Expand All @@ -17,6 +16,7 @@ import type {DiscardChangesConfirmation} from './types';
import type UseDiscardChangesConfirmationOptions from './types';

import getDiscardChangesModalConfig from './getDiscardChangesModalConfig';
import runDiscardConfirmation from './runDiscardConfirmation';

function useDiscardChangesConfirmation({
getHasUnsavedChanges,
Expand All @@ -40,9 +40,7 @@ function useDiscardChangesConfirmation({
});
const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges();

// Also guard tab switches when this screen is an OnyxTabNavigator tab.
// Self-disables outside a tab navigator or without an onTabSwitchDiscard handler
useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel);
useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel);

const showDiscardModal = (blockedAction?: NavigationAction) => {
blockedNavigationAction.current = blockedAction;
Expand All @@ -66,13 +64,9 @@ function useDiscardChangesConfirmation({
}
isReplayingBlockedNavigation.current = false;
};
Promise.resolve()
.then(() => onConfirm?.())
.then(confirmNavigation)
.catch((error: unknown) => {
Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error});
blockedNavigationAction.current = undefined;
});
runDiscardConfirmation(onConfirm, confirmNavigation, () => {
blockedNavigationAction.current = undefined;
});
});
};

Expand Down Expand Up @@ -104,11 +98,11 @@ function useDiscardChangesConfirmation({
return () => subscription.remove();
});

const notifySaving = (isSaving = true) => {
isSavingRef.current = isSaving;
const suppressDiscardPrompt = (shouldSuppress = true) => {
isSavingRef.current = shouldSuppress;
};

return {notifySaving};
return {suppressDiscardPrompt};
}

export default useDiscardChangesConfirmation;
95 changes: 51 additions & 44 deletions src/hooks/useDiscardChangesConfirmation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ 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 {useRegisterTabSwitchGuard} from '@libs/Navigation/TabSwitchGuardContext';
Expand All @@ -18,6 +17,14 @@ import type {DiscardChangesConfirmation} from './types';
import type UseDiscardChangesConfirmationOptions from './types';

import getDiscardChangesModalConfig from './getDiscardChangesModalConfig';
import runDiscardConfirmation from './runDiscardConfirmation';

/**
* Tracks the `history.go(1)` restore round-trip so its echo popstate isn't mistaken for a fresh back: `awaitingRestore`
* = a prevented reset awaiting its popstate; `restoring` = the `go(1)` in flight, awaiting its echo; `dismissModalOnRestore`
* = the back happened over the open prompt.
*/
type RestoreState = {phase: 'idle'} | {phase: 'awaitingRestore'; dismissModalOnRestore: boolean} | {phase: 'restoring'};

function useDiscardChangesConfirmation({
getHasUnsavedChanges,
Expand All @@ -30,16 +37,6 @@ function useDiscardChangesConfirmation({
const {translate} = useLocalize();
const {showConfirmModal, closeModal} = useConfirmModal();

// Also guard tab switches when this screen is an OnyxTabNavigator tab.
// Self-disables outside a tab navigator or without an onTabSwitchDiscard handler
useRegisterTabSwitchGuard(route.name, getHasUnsavedChanges, onTabSwitchDiscard, onCancel);
const blockedNavigationAction = useRef<NavigationAction>(undefined);
const shouldNavigateBack = useRef(false);
const isDiscardModalOpen = useRef(false);
const isRestoringHistory = useRef(false);
const didPreventResetOnPopstate = useRef(false);
const shouldDismissModalOnRestore = useRef(false);

// Only the focused screen should prompt — a flow-leave reset fires `beforeRemove` for hidden siblings too.
const isFocused = useIsFocused();
const isSavingRef = useRef(false);
Expand All @@ -48,6 +45,13 @@ function useDiscardChangesConfirmation({
});
const hasUnsavedChanges = () => isFocused && !isSavingRef.current && getHasUnsavedChanges();

useRegisterTabSwitchGuard(route.name, hasUnsavedChanges, onTabSwitchDiscard, onCancel);

const blockedNavigationAction = useRef<NavigationAction>(undefined);
const shouldNavigateBack = useRef(false);
const isDiscardModalOpen = useRef(false);
const restoreState = useRef<RestoreState>({phase: 'idle'});

const navigateBack = () => {
if (!blockedNavigationAction.current) {
return;
Expand All @@ -66,30 +70,34 @@ function useDiscardChangesConfirmation({
shouldHandleNavigationBack: false,
}).then((result) => {
isDiscardModalOpen.current = false;
didPreventResetOnPopstate.current = false;
shouldDismissModalOnRestore.current = false;
// The awaiting-restore reservation is only meaningful until the prompt resolves; an in-flight `restoring` must survive it.
if (restoreState.current.phase === 'awaitingRestore') {
restoreState.current = {phase: 'idle'};
}
onVisibilityChange?.(false);
if (result.action === ModalActions.CONFIRM) {
Promise.resolve()
.then(() => onConfirm?.())
.then(() => {
setNavigationActionToMicrotaskQueue(navigateBack);
})
.catch((error: unknown) => {
Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error});
blockedNavigationAction.current = undefined;
shouldNavigateBack.current = false;
});
} else {
if (result.action !== ModalActions.CONFIRM) {
blockedNavigationAction.current = undefined;
shouldNavigateBack.current = false;
onCancel?.();
return;
}
runDiscardConfirmation(
onConfirm,
() => setNavigationActionToMicrotaskQueue(navigateBack),
() => {
blockedNavigationAction.current = undefined;
shouldNavigateBack.current = false;
},
);
});
};

useBeforeRemove((e) => {
if (isRestoringHistory.current) {
if (shouldNavigateBack.current) {
return;
}

if (restoreState.current.phase === 'restoring') {
// 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;
Expand All @@ -102,23 +110,24 @@ function useDiscardChangesConfirmation({
if (isDiscardModalOpen.current) {
e.preventDefault();
if (e.data.action.type === 'RESET') {
didPreventResetOnPopstate.current = true;
shouldDismissModalOnRestore.current = true;
restoreState.current = {
phase: 'awaitingRestore',
dismissModalOnRestore: true,
};
return;
}
closeModal();
return;
}

if (shouldNavigateBack.current) {
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;
restoreState.current = {
phase: 'awaitingRestore',
dismissModalOnRestore: false,
};
}
showDiscardModal();
});
Expand All @@ -134,18 +143,16 @@ function useDiscardChangesConfirmation({
* already moved — this listener restores it with `history.go(1)`, and dismisses the prompt as Cancel when the back happened over it.
*/
useEffect(() => {
// Register once: the listener reads the latest `closeModal` through `closeModalRef`, so it never needs to re-subscribe
const handlePopState = () => {
if (isRestoringHistory.current) {
isRestoringHistory.current = false;
const restore = restoreState.current;
if (restore.phase === 'restoring') {
restoreState.current = {phase: 'idle'};
return;
}
if (didPreventResetOnPopstate.current) {
didPreventResetOnPopstate.current = false;
isRestoringHistory.current = true;
if (restore.phase === 'awaitingRestore') {
restoreState.current = {phase: 'restoring'};
window.history.go(1);
if (shouldDismissModalOnRestore.current) {
shouldDismissModalOnRestore.current = false;
if (restore.dismissModalOnRestore) {
closeModalRef.current();
}
}
Expand All @@ -155,11 +162,11 @@ function useDiscardChangesConfirmation({
return () => window.removeEventListener('popstate', handlePopState);
}, []);

const notifySaving = (isSaving = true) => {
isSavingRef.current = isSaving;
const suppressDiscardPrompt = (shouldSuppress = true) => {
isSavingRef.current = shouldSuppress;
};

return {notifySaving};
return {suppressDiscardPrompt};
}

export default useDiscardChangesConfirmation;
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Log from '@libs/Log';

/**
* Await `onConfirm`, then replay the blocked navigation — but if `onConfirm` rejects, log and run `onError` WITHOUT
* navigating, so a failed discard leaves the user in place rather than proceeding silently.
*/
function runDiscardConfirmation(onConfirm: (() => void | Promise<void>) | undefined, navigate: () => void, onError: () => void): void {
Promise.resolve()
.then(() => onConfirm?.())
.then(navigate)
.catch((error: unknown) => {
Log.warn('[useDiscardChangesConfirmation] Failed to run onConfirm callback', {error});
onError();
});
}

export default runDiscardConfirmation;
9 changes: 2 additions & 7 deletions src/hooks/useDiscardChangesConfirmation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,12 @@ type UseDiscardChangesConfirmationOptions = {
onCancel?: () => void;
onVisibilityChange?: (visible: boolean) => void;
onConfirm?: () => void | Promise<void>;

/**
* Discard action for confirming a tab switch. Provide it to guard tab switches inside an `OnyxTabNavigator`.
* Can differ from `onConfirm` (nav-away)
*/
Comment thread
TaduJR marked this conversation as resolved.
onTabSwitchDiscard?: () => void | Promise<void>;
};

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;
/** 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;
};

export default UseDiscardChangesConfirmationOptions;
Expand Down
55 changes: 53 additions & 2 deletions src/libs/MoneyRequestUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import CONST from '@src/CONST';
import type {Report, Transaction} from '@src/types/onyx';
import type {WaypointCollection} from '@src/types/onyx/Transaction';

import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';

import {convertToFrontendAmountAsInteger} from './CurrencyUtils';
import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils';
import {isInvoiceReport, isIOUReport} from './ReportUtils';
import StringUtils from './StringUtils';
import {isExpenseUnreported} from './TransactionUtils';
import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isExpenseUnreported} from './TransactionUtils';
import {isInvalidMerchantValue} from './ValidationUtils';

/**
Expand Down Expand Up @@ -208,6 +209,53 @@ function isValidMerchant(merchant: string | undefined, transaction?: OnyxEntry<T
return valueByteLength <= CONST.MERCHANT_NAME_MAX_BYTES;
}

type AmountHasUnsavedChangesParams = {
typedAmount: string;
committedAmount: number;
isCreateEntry: boolean;
selectedCurrency: string;
originalCurrency: string;
};

/**
* Whether the amount step has unsaved input. Emptiness is judged on the raw string (so a typed "0" counts) and the
* change in backend units (so "5" vs "5.00" isn't a false positive); a currency change counts on its own.
*/
function getAmountHasUnsavedChanges({typedAmount, committedAmount, isCreateEntry, selectedCurrency, originalCurrency}: AmountHasUnsavedChangesParams): boolean {
const currencyChanged = selectedCurrency !== originalCurrency;
if (isCreateEntry) {
return typedAmount !== '' || committedAmount !== 0 || currencyChanged;
}
const typedAmountInBackendUnits = typedAmount ? convertToBackendAmount(Number.parseFloat(typedAmount)) : 0;
return typedAmountInBackendUnits !== committedAmount || currencyChanged;
}

/**
* Whether a raw-string money-request step (hours, manual distance) has unsaved input.
*/
function getStringFieldHasUnsavedChanges(typedValue: string, committedValue: string, isCreateEntry: boolean): boolean {
return isCreateEntry ? typedValue !== '' || committedValue !== '' : typedValue !== committedValue;
}

/**
* Whether the distance (map) step has unsaved waypoints.
*/
function getWaypointsHasUnsavedChanges(
transaction: OnyxEntry<Transaction>,
committedWaypoints: WaypointCollection | undefined,
currentWaypoints: WaypointCollection | undefined,
isCreateEntry: boolean,
): boolean {
if (isCreateEntry) {
return doesMoneyRequestDraftHaveUserInput(transaction);
}
// No committed baseline yet (splits skip the backup; a normal edit's async backup may not have landed) — treat as unchanged.
if (!committedWaypoints) {
return false;
}
return haveWaypointAddressesChanged(committedWaypoints, currentWaypoints);
}

/**
* Determines whether the date field should be shown on the money request confirmation surface.
* This is the single source of truth shared by the confirmation footer (where the date field is rendered)
Expand All @@ -231,4 +279,7 @@ export {
isValidMoneyRequestAmount,
isTaxAmountInvalid,
isValidMerchant,
getAmountHasUnsavedChanges,
getStringFieldHasUnsavedChanges,
getWaypointsHasUnsavedChanges,
};
8 changes: 6 additions & 2 deletions src/libs/Navigation/OnyxTabNavigatorConfig/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ const defaultScreenOptions = {
animation: 'default',
} as const;

/** On native there is no browser history; hardware back returns to the initial tab first, per platform convention. */
const backBehavior: NonNullable<TabRouterOptions['backBehavior']> = 'initialRoute';
/**
* `none` keeps the tab history at a single entry, so back — hardware or header — leaves the whole flow instead of
* returning to the initial tab first. Every OnyxTabNavigator is an RHP/modal flow where back should dismiss it, so
* this matches web and the iOS swipe gesture.
*/
const backBehavior: NonNullable<TabRouterOptions['backBehavior']> = 'none';
Comment thread
TaduJR marked this conversation as resolved.
Comment thread
TaduJR marked this conversation as resolved.

export {defaultScreenOptions, backBehavior};
11 changes: 4 additions & 7 deletions src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1259,17 +1259,14 @@ function hasDisplayableMCC(mcc: number | string | null | undefined): boolean {
return getMCCForDisplay(mcc) !== '';
}

/**
* 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.
*/
/** Whether the draft holds tab-entered input (waypoints) that is lost when the flow is abandoned. */
function doesMoneyRequestDraftHaveUserInput(transaction: OnyxEntry<Transaction>): boolean {
return Object.keys(getValidWaypoints(getWaypoints(transaction))).length > 0;
}

/**
* Return the waypoints field from the transaction, return the modifiedWaypoints if present.
*/
function getWaypoints(transaction: OnyxEntry<Transaction>): WaypointCollection | undefined {
return transaction?.modifiedWaypoints ?? transaction?.comment?.waypoints;
}
Expand Down
Loading
Loading