Skip to content
Merged
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
7 changes: 5 additions & 2 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5859,6 +5859,10 @@ const CONST = {
// Fake history entry used to keep browser Back behavior correct after revealing a route under an RHP.
// addRootHistoryRouterExtension owns when this is added, carried forward, and removed.
CUSTOM_HISTORY_ENTRY_REVEAL_PADDING: 'CUSTOM_HISTORY-REVEAL_PADDING',
// Prefix for the back-guard history entry pushed by a `shouldHandleNavigationBack` Modal. Each
// modal instance appends `${CUSTOM_HISTORY_ENTRY_MODAL}:${modalId}` to the root state.history so
// browser Back closes the modal; the per-instance tag lets nested modals close one at a time.
CUSTOM_HISTORY_ENTRY_MODAL: 'CUSTOM_HISTORY-MODAL',
ACTION_TYPE: {
REPLACE: 'REPLACE',
PUSH: 'PUSH',
Expand All @@ -5874,12 +5878,11 @@ const CONST = {
DISMISS_MODAL: 'DISMISS_MODAL',
REPLACE_FULLSCREEN_UNDER_RHP: 'REPLACE_FULLSCREEN_UNDER_RHP',
REMOVE_FULLSCREEN_UNDER_RHP: 'REMOVE_FULLSCREEN_UNDER_RHP',
OPEN_WORKSPACE_SPLIT: 'OPEN_WORKSPACE_SPLIT',
OPEN_DOMAIN_SPLIT: 'OPEN_DOMAIN_SPLIT',
PUSH_PARAMS: 'PUSH_PARAMS',
REPLACE_PARAMS: 'REPLACE_PARAMS',
TOGGLE_SIDE_PANEL_WITH_HISTORY: 'TOGGLE_SIDE_PANEL_WITH_HISTORY',
TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY: 'TOGGLE_MFA_MODAL_NAVIGATOR_WITH_HISTORY',
TOGGLE_MODAL_WITH_HISTORY: 'TOGGLE_MODAL_WITH_HISTORY',
},
},
TIME_PERIOD: {
Expand Down
28 changes: 0 additions & 28 deletions src/components/DatePicker/DatePickerModal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import {setYear} from 'date-fns';
import React, {useEffect, useRef, useState} from 'react';
import type {View} from 'react-native';
import {isInternalPopstateInProgress} from '@components/Modal/internalPopstateGuard';
import PopoverWithMeasuredContent from '@components/PopoverWithMeasuredContent';
import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import getPlatform from '@libs/getPlatform';
import {setDraftValues} from '@userActions/FormActions';
import CONST from '@src/CONST';
import CalendarPicker from './CalendarPicker';
Expand Down Expand Up @@ -50,32 +48,6 @@ function DatePickerModal({
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();

useEffect(() => {
if (
getPlatform() !== CONST.PLATFORM.WEB ||
!isSmallScreenWidth ||
!isVisible ||
!shouldCloseWhenBrowserNavigationChanged ||
typeof window === 'undefined' ||
typeof window.addEventListener !== 'function'
) {
return;
}

const listener = () => {
if (isInternalPopstateInProgress()) {
return;
}

onClose?.();
};

window.addEventListener('popstate', listener);
return () => {
window.removeEventListener('popstate', listener);
};
}, [isSmallScreenWidth, isVisible, onClose, shouldCloseWhenBrowserNavigationChanged]);

useEffect(() => {
if (shouldSaveDraft && formID) {
setDraftValues(formID, {[inputID]: selectedDate});
Expand Down
71 changes: 6 additions & 65 deletions src/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import React, {useState} from 'react';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import StatusBar from '@libs/StatusBar';
import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import {withInternalPopstate} from './internalPopstateGuard';
import type BaseModalProps from './types';
import type {WindowState} from './types';
import useSyncModalWithHistory from './useSyncModalWithHistory';

type WebModalAnimation = Pick<BaseModalProps, 'animationIn' | 'animationOut' | 'animationInTiming' | 'animationOutTiming'>;

Expand Down Expand Up @@ -66,66 +65,8 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
StatusBar.setBackgroundColor(color);
};

const handlePopStateRef = useRef(() => {
rest.onClose?.();
});

// This useEffect is needed so that when the onClose function changes, the ref contains the current value of this function.
// More information can be found here: https://github.com/Expensify/App/issues/69781
useEffect(() => {
handlePopStateRef.current = () => {
rest.onClose?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rest.onClose]);

// We use a stable callback here to avoid issues with stale closures in event listeners.
// If we directly passed `handlePopStateRef.current` to addEventListener, the listener would
// capture the value of `onClose` at the time it was registered and would not update when
// `onClose` changes. By wrapping it in a stable useCallback and referencing
// handlePopStateRef.current inside, we ensure that the listener always calls the latest
// version of `onClose` without needing to reattach the event listener.
const handlePopState = useCallback(() => {
handlePopStateRef.current();
}, []);

const hideModal = () => {
window.removeEventListener('popstate', handlePopState);
if ((window.history.state as WindowState)?.shouldGoBack && shouldHandleNavigationBack) {
onModalHide();
// Defer history.back() so it runs after any pending navigation
// callbacks (from onModalDidClose) have pushed their history entries.
// This prevents the popstate from undoing navigations triggered by
// menu item selection callbacks.
setTimeout(() => {
if (!(window.history.state as WindowState)?.shouldGoBack) {
return;
}
withInternalPopstate(() => window.history.back());
}, 0);
} else {
onModalHide();
}
};

const showModal = () => {
if (shouldHandleNavigationBack) {
// Preserve React Navigation's state in the guard entry so that if
// popstate fires for this entry, RN recognizes the current route
// and does not perform an unexpected back navigation.
const currentState = (window.history.state ?? {}) as WindowState;
window.history.pushState({...currentState, shouldGoBack: true}, '', null);
window.addEventListener('popstate', handlePopState);
}
onModalShow?.();
};

useEffect(
() => () => {
window.removeEventListener('popstate', handlePopState);
},
[handlePopState],
);
// Back closes the modal (and forward navigation doesn't strand a phantom entry).
useSyncModalWithHistory({isVisible: rest.isVisible, shouldHandleNavigationBack, onClose: rest.onClose});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the modal cleanup marker

When a shouldHandleNavigationBack modal is the discard-confirmation dialog, useDiscardChangesConfirmation.markNextBeforeRemoveAsModalCleanup() still checks window.history.state.shouldGoBack before ignoring the modal's synthetic cleanup back. This new sentinel path no longer writes that flag, so on web pages with unsaved changes, dismissing or confirming the discard dialog can let the modal-history cleanup be treated as a real back navigation and re-trigger/block the original beforeRemove flow. Please either keep an equivalent marker in history state or update that hook before routing all modals through this path.

Useful? React with 👍 / 👎.


const onModalWillShow = () => {
const statusBarColor = StatusBar.getBackgroundColor() ?? theme.appBG;
Expand Down Expand Up @@ -156,8 +97,8 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
animationOut={rest.animationOut ?? webAnimation.animationOut}
animationInTiming={rest.animationInTiming ?? webAnimation.animationInTiming}
animationOutTiming={rest.animationOutTiming ?? webAnimation.animationOutTiming}
onModalHide={hideModal}
onModalShow={showModal}
onModalHide={onModalHide}
onModalShow={onModalShow}
onModalWillShow={onModalWillShow}
onModalWillHide={onModalWillHide}
avoidKeyboard={false}
Expand Down
19 changes: 0 additions & 19 deletions src/components/Modal/internalPopstateGuard.ts

This file was deleted.

6 changes: 1 addition & 5 deletions src/components/Modal/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ type PopoverAnchorPosition = {
left?: number;
};

type WindowState = {
shouldGoBack: boolean;
};

type BaseModalProps = Partial<ReanimatedModalProps> &
ForwardedFSClassProps & {
/** Decides whether the modal should cover fullscreen. FullScreen modal has backdrop */
Expand Down Expand Up @@ -141,4 +137,4 @@ type BaseModalProps = Partial<ReanimatedModalProps> &
};

export default BaseModalProps;
export type {PopoverAnchorPosition, FocusTrapOptions, WindowState};
export type {PopoverAnchorPosition, FocusTrapOptions};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Modal back-guard synchronization with the browser history is only supported for web.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: is this to limit scope, or because the problem doesn't exist on native? Asking because I know Android has a native back button

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this PR, we changed the approach for the web only, since previously a workaround for the back button on the web was used only in modal windows—because that approach relies on window, which exists only on the web.

Here is the current code snippet:

const showModal = () => {
if (shouldHandleNavigationBack) {
// Preserve React Navigation's state in the guard entry so that if
// popstate fires for this entry, RN recognizes the current route
// and does not perform an unexpected back navigation.
const currentState = (window.history.state ?? {}) as WindowState;
window.history.pushState({...currentState, shouldGoBack: true}, '', null);
window.addEventListener('popstate', handlePopState);
}
onModalShow?.();
};
useEffect(
() => () => {
window.removeEventListener('popstate', handlePopState);
},
[handlePopState],
);

A different approach is used on native devices, and this PR does not change anything for native devices

export default function useSyncModalWithHistory() {}
127 changes: 127 additions & 0 deletions src/components/Modal/useSyncModalWithHistory/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import {useEffect, useEffectEvent, useId, useRef, useSyncExternalStore} from 'react';
import subscribeToRootNavigation from '@libs/Navigation/helpers/subscribeToRootNavigation';
import Navigation from '@libs/Navigation/Navigation';
import navigationRef from '@libs/Navigation/navigationRef';
import CONST from '@src/CONST';
import {EMPTY_MODAL_GUARD_SNAPSHOT_KEY, getModalGuardSnapshotKey, parseModalGuardSnapshotKey} from './modalGuardSnapshot';
import reduceModalGuardState, {getModalGuardEventFromSnapshotChange, MODAL_GUARD_EFFECT, MODAL_GUARD_STATE} from './modalGuardState';
import type {ModalGuardState} from './modalGuardState';

type UseSyncModalWithHistoryParams = {
/** Whether the modal is currently visible */
isVisible: boolean;

/** Whether this modal participates in browser-history back handling */
shouldHandleNavigationBack?: boolean;

/** Called when a browser Back press removes this modal's history entry */
onClose?: () => void;

/** Called when browser Forward navigation restores this modal's history entry while the modal is closed */
onOpen?: () => void;
};

/**
* Web: represents a `shouldHandleNavigationBack` modal's back-guard as a uniquely-tagged sentinel in the
* root navigator's `state.history` (dispatched via `TOGGLE_MODAL_WITH_HISTORY`). React Navigation's
* `useLinking` mirrors the history length delta into the browser, so opening the modal pushes a browser
* entry and browser Back removes it. The router consumes the sentinel on forward navigation
* (history length unchanged → `replaceState`) so no orphaned entry is left behind.
*
* The per-instance tag lets nested modals add/remove their own guard independently (LIFO).
*/
export default function useSyncModalWithHistory({isVisible, shouldHandleNavigationBack, onClose, onOpen}: UseSyncModalWithHistoryParams) {
const modalId = useId();
const sentinel = `${CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_MODAL}:${modalId}`;

const guardStateRef = useRef<ModalGuardState>(MODAL_GUARD_STATE.CLOSED);

const onCloseEvent = useEffectEvent(() => {
onClose?.();
});

const onOpenEvent = useEffectEvent(() => {
onOpen?.();
});

const snapshotKey = useSyncExternalStore(
subscribeToRootNavigation,
() => getModalGuardSnapshotKey(sentinel),
() => EMPTY_MODAL_GUARD_SNAPSHOT_KEY,
);
// We can't use usePrevious here because we need to imperatively reset this ref mid-effect
// (to sync up when shouldHandleNavigationBack is false, so no stale transition fires when
// the flag toggles back on). usePrevious only updates after render and is read-only to callers.
const prevSnapshotKeyRef = useRef(snapshotKey);
Comment thread
rlinoz marked this conversation as resolved.

// Add the guard entry on open / remove it on close.
useEffect(() => {
if (!shouldHandleNavigationBack) {
return;
}

if (isVisible) {
guardStateRef.current = MODAL_GUARD_STATE.OPEN;
Navigation.isNavigationReady().then(() => {
navigationRef.dispatch({
type: CONST.NAVIGATION.ACTION_TYPE.TOGGLE_MODAL_WITH_HISTORY,
payload: {isVisible: true, modalId},
});
});
Comment thread
rlinoz marked this conversation as resolved.
return () => {
if (guardStateRef.current !== MODAL_GUARD_STATE.OPEN) {
return;
}
guardStateRef.current = MODAL_GUARD_STATE.CLOSED;
navigationRef.dispatch({
type: CONST.NAVIGATION.ACTION_TYPE.TOGGLE_MODAL_WITH_HISTORY,
payload: {isVisible: false, modalId},
});
};
}

if (guardStateRef.current !== MODAL_GUARD_STATE.OPEN) {
return;
}
guardStateRef.current = MODAL_GUARD_STATE.CLOSING_BY_DISPATCH;
// Defer (microtask via isNavigationReady) so any forward navigation fired from the same close
// handler is dispatched first; the router then consumes our sentinel during that push and this
// toggle(false) becomes a no-op, avoiding an extra browser back().
Navigation.isNavigationReady().then(() => {
navigationRef.dispatch({
type: CONST.NAVIGATION.ACTION_TYPE.TOGGLE_MODAL_WITH_HISTORY,
payload: {isVisible: false, modalId},
});
});
}, [isVisible, shouldHandleNavigationBack, modalId]);

// Browser Back/Forward changes the guard sentinel in root history — react via snapshot transitions.
useEffect(() => {
if (!shouldHandleNavigationBack) {
prevSnapshotKeyRef.current = snapshotKey;
return;
}

if (prevSnapshotKeyRef.current === snapshotKey) {
return;
}

const prevSnapshot = parseModalGuardSnapshotKey(prevSnapshotKeyRef.current);
const nextSnapshot = parseModalGuardSnapshotKey(snapshotKey);
prevSnapshotKeyRef.current = snapshotKey;

const event = getModalGuardEventFromSnapshotChange(prevSnapshot, nextSnapshot, isVisible);
if (!event) {
return;
}

const {state, effect} = reduceModalGuardState(guardStateRef.current, event, isVisible);
guardStateRef.current = state;

if (effect === MODAL_GUARD_EFFECT.ON_CLOSE) {
onCloseEvent();
} else if (effect === MODAL_GUARD_EFFECT.ON_OPEN) {
onOpenEvent();
}
}, [snapshotKey, isVisible, shouldHandleNavigationBack]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import navigationRef from '@libs/Navigation/navigationRef';

// Slice of navigation state consumed by useSyncExternalStore to detect guard presence and route-count changes.
type ModalGuardSnapshot = {
guardPresent: boolean;
routesLength: number;
};
Comment on lines +4 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add comment here


const EMPTY_MODAL_GUARD_SNAPSHOT: ModalGuardSnapshot = {
guardPresent: false,
routesLength: 0,
};

const EMPTY_MODAL_GUARD_SNAPSHOT_KEY = 'false:0';

function getModalGuardSnapshot(sentinel: string): ModalGuardSnapshot {
if (!navigationRef.isReady()) {
return EMPTY_MODAL_GUARD_SNAPSHOT;
}

const state = navigationRef.getRootState();
return {
guardPresent: !!state?.history?.includes(sentinel),
routesLength: state?.routes?.length ?? 0,
};
}

function serializeModalGuardSnapshot(snapshot: ModalGuardSnapshot): string {
return `${snapshot.guardPresent}:${snapshot.routesLength}`;
}

function getModalGuardSnapshotKey(sentinel: string): string {
return serializeModalGuardSnapshot(getModalGuardSnapshot(sentinel));
}

function parseModalGuardSnapshotKey(snapshotKey: string): ModalGuardSnapshot {
const [guardPresent, routesLength] = snapshotKey.split(':');
return {
guardPresent: guardPresent === 'true',
routesLength: Number(routesLength),
};
}

export type {ModalGuardSnapshot};
export {EMPTY_MODAL_GUARD_SNAPSHOT_KEY, getModalGuardSnapshotKey, parseModalGuardSnapshotKey};
Loading
Loading