diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx
index 51612078a88c..23a08b4306e3 100755
--- a/src/components/ConfirmModal.tsx
+++ b/src/components/ConfirmModal.tsx
@@ -99,7 +99,7 @@ function ConfirmModal({
image,
shouldEnableNewFocusManagement,
}: ConfirmModalProps) {
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {isSmallScreenWidth} = useResponsiveLayout();
const styles = useThemeStyles();
return (
@@ -109,7 +109,7 @@ function ConfirmModal({
isVisible={isVisible}
shouldSetModalVisibility={shouldSetModalVisibility}
onModalHide={onModalHide}
- type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
+ type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
innerContainerStyle={image ? styles.pt0 : {}}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
>
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index 198e641840a7..e24c5b7c9c80 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -4,7 +4,6 @@ import ReactNativeModal from 'react-native-modal';
import ColorSchemeWrapper from '@components/ColorSchemeWrapper';
import useKeyboardState from '@hooks/useKeyboardState';
import usePrevious from '@hooks/usePrevious';
-import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
@@ -17,6 +16,7 @@ import variables from '@styles/variables';
import * as Modal from '@userActions/Modal';
import CONST from '@src/CONST';
import ModalContent from './ModalContent';
+import ModalContext from './ModalContext';
import type BaseModalProps from './types';
function BaseModal(
@@ -55,8 +55,7 @@ function BaseModal(
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const {windowWidth, windowHeight} = useWindowDimensions();
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {isSmallScreenWidth, windowWidth, windowHeight} = useWindowDimensions();
const keyboardStateContextValue = useKeyboardState();
const safeAreaInsets = useSafeAreaInsets();
@@ -163,13 +162,13 @@ function BaseModal(
{
windowWidth,
windowHeight,
- isSmallScreenWidth: shouldUseNarrowLayout,
+ isSmallScreenWidth,
},
popoverAnchorPosition,
innerContainerStyle,
outerStyle,
),
- [StyleUtils, type, windowWidth, windowHeight, shouldUseNarrowLayout, popoverAnchorPosition, innerContainerStyle, outerStyle],
+ [StyleUtils, type, windowWidth, windowHeight, isSmallScreenWidth, popoverAnchorPosition, innerContainerStyle, outerStyle],
);
const {
@@ -200,60 +199,69 @@ function BaseModal(
paddingRight: safeAreaPaddingRight ?? 0,
};
+ const modalContextValue = useMemo(
+ () => ({
+ activeModalType: isVisible ? type : undefined,
+ }),
+ [isVisible, type],
+ );
+
return (
- // this is a workaround for modal not being visible on the new arch in some cases
- // it's necessary to have a non-collapseable view as a parent of the modal to prevent
- // a conflict between RN core and Reanimated shadow tree operations
- // position absolute is needed to prevent the view from interfering with flex layout
-
- e.stopPropagation()}
- onBackdropPress={handleBackdropPress}
- // Note: Escape key on web/desktop will trigger onBackButtonPress callback
- // eslint-disable-next-line react/jsx-props-no-multi-spaces
- onBackButtonPress={Modal.closeTop}
- onModalShow={handleShowModal}
- propagateSwipe={propagateSwipe}
- onModalHide={hideModal}
- onModalWillShow={saveFocusState}
- onDismiss={handleDismissModal}
- onSwipeComplete={() => onClose?.()}
- swipeDirection={swipeDirection}
- isVisible={isVisible}
- backdropColor={theme.overlay}
- backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity}
- backdropTransitionOutTiming={0}
- hasBackdrop={fullscreen}
- coverScreen={fullscreen}
- style={modalStyle}
- deviceHeight={windowHeight}
- deviceWidth={windowWidth}
- animationIn={animationIn ?? modalStyleAnimationIn}
- animationOut={animationOut ?? modalStyleAnimationOut}
- useNativeDriver={useNativeDriverProp && useNativeDriver}
- useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver}
- hideModalContentWhileAnimating={hideModalContentWhileAnimating}
- animationInTiming={animationInTiming}
- animationOutTiming={animationOutTiming}
- statusBarTranslucent={statusBarTranslucent}
- onLayout={onLayout}
- avoidKeyboard={avoidKeyboard}
- customBackdrop={shouldUseCustomBackdrop ? : undefined}
+
+
-
-
- {children}
-
-
-
-
+ e.stopPropagation()}
+ onBackdropPress={handleBackdropPress}
+ // Note: Escape key on web/desktop will trigger onBackButtonPress callback
+ // eslint-disable-next-line react/jsx-props-no-multi-spaces
+ onBackButtonPress={Modal.closeTop}
+ onModalShow={handleShowModal}
+ propagateSwipe={propagateSwipe}
+ onModalHide={hideModal}
+ onModalWillShow={saveFocusState}
+ onDismiss={handleDismissModal}
+ onSwipeComplete={() => onClose?.()}
+ swipeDirection={swipeDirection}
+ isVisible={isVisible}
+ backdropColor={theme.overlay}
+ backdropOpacity={!shouldUseCustomBackdrop && hideBackdrop ? 0 : variables.overlayOpacity}
+ backdropTransitionOutTiming={0}
+ hasBackdrop={fullscreen}
+ coverScreen={fullscreen}
+ style={modalStyle}
+ deviceHeight={windowHeight}
+ deviceWidth={windowWidth}
+ animationIn={animationIn ?? modalStyleAnimationIn}
+ animationOut={animationOut ?? modalStyleAnimationOut}
+ useNativeDriver={useNativeDriverProp && useNativeDriver}
+ useNativeDriverForBackdrop={useNativeDriverForBackdrop && useNativeDriver}
+ hideModalContentWhileAnimating={hideModalContentWhileAnimating}
+ animationInTiming={animationInTiming}
+ animationOutTiming={animationOutTiming}
+ statusBarTranslucent={statusBarTranslucent}
+ onLayout={onLayout}
+ avoidKeyboard={avoidKeyboard}
+ customBackdrop={shouldUseCustomBackdrop ? : undefined}
+ >
+
+
+ {children}
+
+
+
+
+
);
}
diff --git a/src/components/Modal/ModalContext.ts b/src/components/Modal/ModalContext.ts
new file mode 100644
index 000000000000..a5c2eb21ab1d
--- /dev/null
+++ b/src/components/Modal/ModalContext.ts
@@ -0,0 +1,15 @@
+import {createContext} from 'react';
+import type ModalType from '@src/types/utils/ModalType';
+
+type ModalContextType = {
+ // The type of the currently displayed modal, or undefined if there is no currently displayed modal.
+ // Note that React Native can only display one modal at a time.
+ activeModalType?: ModalType;
+};
+
+// This context is meant to inform modal children that they are rendering in a modal (and what type of modal they are rendering in)
+// Note that this is different than ONYXKEYS.MODAL.isVisible data point in that that is a global variable for whether a modal is visible or not,
+// whereas this context is provided by the BaseModal component, and thus is only available to components rendered inside a modal.
+const ModalContext = createContext({});
+
+export default ModalContext;
diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx
index 2af977caa969..3ef267292e90 100644
--- a/src/components/Popover/index.tsx
+++ b/src/components/Popover/index.tsx
@@ -28,7 +28,7 @@ function Popover(props: PopoverProps) {
animationOut = 'fadeOut',
} = props;
- const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {isSmallScreenWidth} = useResponsiveLayout();
const withoutOverlayRef = useRef(null);
const {close, popover} = React.useContext(PopoverContext);
@@ -55,7 +55,7 @@ function Popover(props: PopoverProps) {
onClose();
};
- if (!fullscreen && !shouldUseNarrowLayout) {
+ if (!fullscreen && !isSmallScreenWidth) {
return createPortal(
(null);
const [currentMenuItems, setCurrentMenuItems] = useState(menuItems);
@@ -198,7 +198,7 @@ function PopoverMenu({
shouldSetModalVisibility={shouldSetModalVisibility}
shouldEnableNewFocusManagement={shouldEnableNewFocusManagement}
>
-
+
{!!headerText && {headerText}}
{enteredSubMenuIndexes.length > 0 && renderBackButtonItem()}
{currentMenuItems.map((item, menuIndex) => (
diff --git a/src/hooks/useResponsiveLayout.ts b/src/hooks/useResponsiveLayout.ts
index bf0b77ef915c..18b29653479c 100644
--- a/src/hooks/useResponsiveLayout.ts
+++ b/src/hooks/useResponsiveLayout.ts
@@ -1,15 +1,14 @@
-import {useEffect, useRef, useState} from 'react';
-import type {OnyxEntry} from 'react-native-onyx';
-import {useOnyx} from 'react-native-onyx';
-import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {Modal} from '@src/types/onyx';
+import {useContext} from 'react';
+import ModalContext from '@components/Modal/ModalContext';
+import Navigation from '@libs/Navigation/Navigation';
+import CONST from '@src/CONST';
+import useRootNavigationState from './useRootNavigationState';
import useWindowDimensions from './useWindowDimensions';
type ResponsiveLayoutResult = {
shouldUseNarrowLayout: boolean;
isSmallScreenWidth: boolean;
- isInModal: boolean;
+ isInNarrowPaneModal: boolean;
isExtraSmallScreenHeight: boolean;
isMediumScreenWidth: boolean;
isLargeScreenWidth: boolean;
@@ -19,39 +18,47 @@ type ResponsiveLayoutResult = {
/**
* Hook to determine if we are on mobile devices or in the Modal Navigator.
- * Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInModal" for "in RHP/LHP".
+ * Use "shouldUseNarrowLayout" for "on mobile or in RHP/LHP", "isSmallScreenWidth" for "on mobile", "isInNarrowPaneModal" for "in RHP/LHP".
+ *
+ * There are two kinds of modals in this app:
+ * 1. Modal stack navigators from react-navigation
+ * 2. Modal components that use react-native-modal
+ *
+ * This hook is designed to handle both. `shouldUseNarrowLayout` will return `true` if any of the following are true:
+ * 1. The device screen width is narrow
+ * 2. The consuming component is the child of a "right docked" react-native-modal component
+ * 3. The consuming component is a screen in a modal stack navigator and not a child of a "non-right-docked" react-native-modal component.
+ *
+ * For more details on the various modal types we've defined for this app and implemented using react-native-modal, see `ModalType`.
*/
export default function useResponsiveLayout(): ResponsiveLayoutResult {
const {isSmallScreenWidth, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen} = useWindowDimensions();
- const [willAlertModalBecomeVisible] = useOnyx(ONYXKEYS.MODAL, {selector: (value: OnyxEntry) => value?.willAlertModalBecomeVisible ?? false});
+ // Note: activeModalType refers to our react-native-modal component wrapper, not react-navigation's modal stack navigators.
+ // This means it will only be defined if the component calling this hook is a child of a modal component. See BaseModal for the provider.
+ const {activeModalType} = useContext(ModalContext);
- const [isInModal, setIsInModal] = useState(false);
- const hasSetIsInModal = useRef(false);
- const updateModalStatus = () => {
- if (hasSetIsInModal.current) {
- return;
- }
- const isDisplayedInModal = Navigation.isDisplayedInModal();
- if (isInModal !== isDisplayedInModal) {
- setIsInModal(isDisplayedInModal);
- }
- hasSetIsInModal.current = true;
- };
-
- useEffect(() => {
- const unsubscribe = navigationRef?.current?.addListener('state', updateModalStatus);
+ // This refers to the state of the root navigator, and is true if and only if the topmost navigator is the "left modal navigator" or the "right modal navigator"
+ const isDisplayedInModalNavigator = !!useRootNavigationState(Navigation.isModalNavigatorActive);
- if (navigationRef?.current?.isReady()) {
- updateModalStatus();
- }
+ // The component calling this hook is in a "narrow pane modal" if:
+ const isInNarrowPaneModal =
+ // it's a child of the right-docked modal
+ activeModalType === CONST.MODAL.MODAL_TYPE.RIGHT_DOCKED ||
+ // or there's a "right modal navigator" or "left modal navigator" on the top of the root navigation stack
+ // and the component calling this hook is not the child of another modal type, such as a confirm modal
+ (isDisplayedInModalNavigator && !activeModalType);
- return () => {
- unsubscribe?.();
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
+ const shouldUseNarrowLayout = isSmallScreenWidth || isInNarrowPaneModal;
- const shouldUseNarrowLayout = willAlertModalBecomeVisible ? isSmallScreenWidth : isSmallScreenWidth || isInModal;
- return {shouldUseNarrowLayout, isSmallScreenWidth, isInModal, isExtraSmallScreenHeight, isExtraSmallScreenWidth, isMediumScreenWidth, isLargeScreenWidth, isSmallScreen};
+ return {
+ shouldUseNarrowLayout,
+ isSmallScreenWidth,
+ isInNarrowPaneModal,
+ isExtraSmallScreenHeight,
+ isExtraSmallScreenWidth,
+ isMediumScreenWidth,
+ isLargeScreenWidth,
+ isSmallScreen,
+ };
}
diff --git a/src/hooks/useRootNavigationState.ts b/src/hooks/useRootNavigationState.ts
new file mode 100644
index 000000000000..3f03e1ceb253
--- /dev/null
+++ b/src/hooks/useRootNavigationState.ts
@@ -0,0 +1,30 @@
+import type {EventListenerCallback, NavigationContainerEventMap} from '@react-navigation/native';
+import type {NavigationState} from '@react-navigation/routers';
+import {useCallback, useSyncExternalStore} from 'react';
+import {navigationRef} from '@libs/Navigation/Navigation';
+
+/**
+ * This hook is a replacement for `useNavigationState` for nested navigators.
+ * If `useNavigationState` is used within a nested navigator then the state that's returned is the state of the nearest parent navigator,
+ * not the root navigator state representing the whole app's navigation tree.
+ *
+ * Use with caution, because re-rendering any component every time the root navigation state changes can be very costly for performance.
+ * That's why the selector is mandatory.
+ */
+function useRootNavigationState(selector: (state: NavigationState) => T): T | undefined {
+ const getSnapshot = useCallback(() => {
+ if (!navigationRef?.current) {
+ return;
+ }
+ return selector(navigationRef.current.getRootState());
+ }, [selector]);
+
+ const subscribeToRootState = useCallback((callback: EventListenerCallback) => {
+ const unsubscribe = navigationRef?.current?.addListener('state', callback);
+ return () => unsubscribe?.();
+ }, []);
+
+ return useSyncExternalStore(subscribeToRootState, getSnapshot);
+}
+
+export default useRootNavigationState;
diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts
index 3fa3e0c5c317..80ce5e65b702 100644
--- a/src/libs/Navigation/Navigation.ts
+++ b/src/libs/Navigation/Navigation.ts
@@ -1,5 +1,5 @@
import {findFocusedRoute} from '@react-navigation/core';
-import type {EventArg, NavigationContainerEventMap} from '@react-navigation/native';
+import type {EventArg, NavigationContainerEventMap, NavigationState} from '@react-navigation/native';
import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native';
import Log from '@libs/Log';
import * as ReportUtils from '@libs/ReportUtils';
@@ -356,9 +356,12 @@ function navigateWithSwitchPolicyID(params: SwitchPolicyIDParams) {
return switchPolicyID(navigationRef.current, params);
}
-/** Check if the modal is being displayed */
-function isDisplayedInModal() {
- const state = navigationRef?.current?.getRootState();
+/**
+ * Check if the modal is being displayed.
+ *
+ * @param state - MUST be the state of the root navigator for this to work. Do not use a child navigator state.
+ */
+function isModalNavigatorActive(state: NavigationState) {
const lastRoute = state?.routes?.at(-1);
const lastRouteName = lastRoute?.name;
return lastRouteName === NAVIGATORS.LEFT_MODAL_NAVIGATOR || lastRouteName === NAVIGATORS.RIGHT_MODAL_NAVIGATOR;
@@ -383,7 +386,7 @@ export default {
parseHybridAppUrl,
navigateWithSwitchPolicyID,
resetToHome,
- isDisplayedInModal,
+ isModalNavigatorActive,
closeRHPFlow,
setNavigationActionToMicrotaskQueue,
};
diff --git a/src/pages/signin/LoginForm/BaseLoginForm.tsx b/src/pages/signin/LoginForm/BaseLoginForm.tsx
index 4286a2603341..973eaa2d8907 100644
--- a/src/pages/signin/LoginForm/BaseLoginForm.tsx
+++ b/src/pages/signin/LoginForm/BaseLoginForm.tsx
@@ -64,7 +64,7 @@ function BaseLoginForm({account, credentials, closeAccount, blurOnSubmit = false
const firstBlurred = useRef(false);
const isFocused = useIsFocused();
const isLoading = useRef(false);
- const {shouldUseNarrowLayout, isInModal} = useResponsiveLayout();
+ const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout();
/**
* Validate the input value and set the error for formError
@@ -167,7 +167,7 @@ function BaseLoginForm({account, credentials, closeAccount, blurOnSubmit = false
return;
}
let focusTimeout: NodeJS.Timeout;
- if (isInModal) {
+ if (isInNarrowPaneModal) {
focusTimeout = setTimeout(() => input.current?.focus(), CONST.ANIMATED_TRANSITION);
} else {
input.current.focus();
diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx
index 16aeb7053726..112d350087b1 100644
--- a/src/pages/signin/SignInPage.tsx
+++ b/src/pages/signin/SignInPage.tsx
@@ -130,7 +130,7 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate, formatPhoneNumber} = useLocalize();
- const {shouldUseNarrowLayout, isInModal} = useResponsiveLayout();
+ const {shouldUseNarrowLayout, isInNarrowPaneModal} = useResponsiveLayout();
const safeAreaInsets = useSafeAreaInsets();
const signInPageLayoutRef = useRef(null);
const loginFormRef = useRef(null);
@@ -250,7 +250,7 @@ function SignInPageInner({credentials, account, activeClients = [], preferredLoc