Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9d260ae
fix: show discard changes confirmation on browser back
TaduJR Jun 11, 2026
8097dc1
fix: skip discard confirmation while a save is navigating
TaduJR Jun 11, 2026
4a016aa
docs: link patch registry entry to introducing PR
TaduJR Jun 11, 2026
b36d6af
fix: count currency and sign changes as unsaved input in the amount d…
TaduJR Jun 11, 2026
14744d2
fix: guard waypoint loss at the distance tab screens instead of the s…
TaduJR Jun 11, 2026
4049193
chore: drop redundant comment
TaduJR Jun 11, 2026
884f5c0
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 11, 2026
5338e4d
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 11, 2026
91005c5
refactor: regenerate beforeRemove propagation patch from upstream fix
TaduJR Jun 12, 2026
e2556cf
fix: make web tab switches history-inert so browser back leaves the flow
TaduJR Jun 12, 2026
7330759
chore: import TabRouterOptions from the listed @react-navigation/nati…
TaduJR Jun 12, 2026
3a17596
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 13, 2026
5287396
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 14, 2026
a4b9036
refactor: regenerate core patch from merged upstream fix
TaduJR Jun 14, 2026
ce9783b
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 17, 2026
cc7b9a5
docs: correct the link for the upstream PR related to the patch
TaduJR Jun 17, 2026
1501f2f
fix: suppress discard prompt on the Hours restricted-action navigation
TaduJR Jun 17, 2026
2134634
Merge branch 'fix-Discard-changes-modal-doesnt-show-up-when-going-bac…
TaduJR Jun 17, 2026
f2dc8a7
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 18, 2026
a3d724a
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 27, 2026
2a4a53a
Merge branch 'main' of https://github.com/TaduJR/App into fix-Discard…
TaduJR Jun 28, 2026
eedf3c4
refactor: own the focus/saving gate in useDiscardChangesConfirmation
TaduJR Jun 28, 2026
ff72f45
refactor: split the native saving-ref reset into its own focus effect
TaduJR Jun 29, 2026
1be4583
test: cover notifySaving and the focus gate on web and native
TaduJR Jun 29, 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
Original file line number Diff line number Diff line change
@@ -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]);
9 changes: 9 additions & 0 deletions patches/react-navigation/details.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
130 changes: 83 additions & 47 deletions src/hooks/useDiscardChangesConfirmation/index.native.ts
Original file line number Diff line number Diff line change
@@ -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<NavigationAction | undefined>(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;
Loading
Loading