diff --git a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx index 43336e3bc074..9524756158cb 100644 --- a/src/components/ScreenWrapper/ScreenWrapperContainer.tsx +++ b/src/components/ScreenWrapper/ScreenWrapperContainer.tsx @@ -6,6 +6,7 @@ import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddi import useInitialDimensions from '@hooks/useInitialWindowDimensions'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useTackInputFocus from '@hooks/useTackInputFocus'; +import useTestToolsTapResponder from '@hooks/useTestToolsTapResponder'; import useThemeStyles from '@hooks/useThemeStyles'; import useViewportOffsetTop from '@hooks/useViewportOffsetTop'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -14,14 +15,12 @@ import {isMobile, isMobileWebKit, isSafari} from '@libs/Browser'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import addViewportResizeListener from '@libs/VisualViewport'; -import toggleTestToolsModal from '@userActions/TestTool'; - import CONST from '@src/CONST'; import type {ComponentRef, ForwardedRef, ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; -import React, {useContext, useEffect, useMemo, useRef} from 'react'; +import React, {useContext, useEffect, useMemo, useState} from 'react'; import {Keyboard, PanResponder, View} from 'react-native'; import {PickerAvoidingView} from 'react-native-picker-select'; @@ -161,24 +160,20 @@ function ScreenWrapperContainer({ [isUsingEdgeToEdgeMode, edgeToEdgeBottomContentStyle, legacyBottomContentStyle, bottomContentStyleProp], ); - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponderCapture: (_e, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, - onPanResponderRelease: toggleTestToolsModal, - }), - ).current; - - const keyboardDismissPanResponder = useRef( - PanResponder.create({ - onMoveShouldSetPanResponderCapture: (_e, gestureState) => { - const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy); - const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && Keyboard.isVisible() && isMobile(); - - return isHorizontalSwipe && shouldDismissKeyboard; - }, - onPanResponderGrant: Keyboard.dismiss, - }), - ).current; + const testToolsTapResponder = useTestToolsTapResponder(); + + const [keyboardDismissPanResponder] = useState( + () => + PanResponder.create({ + onMoveShouldSetPanResponderCapture: (_e, gestureState) => { + const isHorizontalSwipe = Math.abs(gestureState.dx) > Math.abs(gestureState.dy); + const shouldDismissKeyboard = shouldDismissKeyboardBeforeClose && Keyboard.isVisible() && isMobile(); + + return isHorizontalSwipe && shouldDismissKeyboard; + }, + onPanResponderGrant: Keyboard.dismiss, + }).panHandlers, + ); useEffect(() => { /** @@ -206,7 +201,7 @@ function ScreenWrapperContainer({ ref={ref} // This style gives the background for the screens. Stack cards are transparent to make different width screens in RHP possible. style={[styles.flex1, styles.appBG, styles.screenWrapperContainerMinHeight(minHeight)]} - {...panResponder.panHandlers} + {...testToolsTapResponder} testID={testID} fsClass={forwardedFSClass} tabIndex={-1} @@ -215,7 +210,7 @@ function ScreenWrapperContainer({ > ([NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR, SCREENS.TEST_TOOLS_MODAL.ROOT, SCREENS.TEST_TOOLS_MODAL.SERVER]); + +/** + * Classifies the focused screen for the required-2FA overlay. + * Returns a primitive so navigation listeners can skip unchanged values. + */ +function getRequire2FAFocusedScreen(screenName: string | undefined): Require2FAFocusedScreen { + if (isTwoFactorSetupScreen(screenName)) { + return REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP; + } + if (screenName && TEST_TOOLS_SCREENS.has(screenName)) { + return REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS; + } + return REQUIRE_2FA_FOCUSED_SCREEN.OTHER; +} + +type Require2FAOverlayVisibility = { + /** The blocking required-2FA screen is mounted over the app. */ + isRequire2FAOverlayVisible: boolean; + /** Test tools are the focused route, so they must sit above the overlay and take input. */ + isTestToolsRouteFocused: boolean; +}; + +/** + * Shared by the required-2FA overlay and the test-tools navigator. + * Test tools move above the overlay only while the overlay is covering the root stack. + */ +function useRequire2FAOverlayVisibility(): Require2FAOverlayVisibility { + const navigation = useNavigation(); + const shouldShowRequire2FAPage = useShouldShowRequire2FAPage(); + const focusedScreen = useRootNavigationState((state) => { + // When navigation is not ready yet, use the navigation state from the navigation hook. + const screenName = getDeepestFocusedScreen(state ?? navigation.getState())?.name; + return getRequire2FAFocusedScreen(screenName); + }); + + return { + isRequire2FAOverlayVisible: shouldShowRequire2FAPage && focusedScreen !== REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP, + isTestToolsRouteFocused: focusedScreen === REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, + }; +} + +export default useRequire2FAOverlayVisibility; +export {REQUIRE_2FA_ABOVE_PORTAL_HOST, REQUIRE_2FA_FOCUSED_SCREEN, getRequire2FAFocusedScreen}; diff --git a/src/hooks/useTestToolsTapResponder.ts b/src/hooks/useTestToolsTapResponder.ts new file mode 100644 index 000000000000..e7f98301e172 --- /dev/null +++ b/src/hooks/useTestToolsTapResponder.ts @@ -0,0 +1,21 @@ +import toggleTestToolsModal from '@userActions/TestTool'; + +import CONST from '@src/CONST'; + +import {useState} from 'react'; +import {PanResponder} from 'react-native'; + +/** Four-finger tap that opens test tools. Shared by screen wrappers and the required-2FA overlay, which sits above those screens. */ +function useTestToolsTapResponder() { + const [panHandlers] = useState( + () => + PanResponder.create({ + onStartShouldSetPanResponderCapture: (_event, gestureState) => gestureState.numberActiveTouches === CONST.TEST_TOOL.NUMBER_OF_TAPS, + onPanResponderRelease: toggleTestToolsModal, + }).panHandlers, + ); + + return panHandlers; +} + +export default useTestToolsTapResponder; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index 21ee4725483c..0bfb54f088d8 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -24,11 +24,13 @@ import WideRHPContextProvider from '@components/WideRHPContextProvider'; import useGPSInProgressModal from '@hooks/useGPSInProgressModal'; import useGPSTripStateChecker from '@hooks/useGPSTripStateChecker'; import useOnboardingFlowRouter from '@hooks/useOnboardingFlow'; +import {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShouldSuppressPromotionalUI from '@hooks/useShouldSuppressPromotionalUI'; import {SidebarOrderedReportsContextProvider} from '@hooks/useSidebarOrderedReports'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; import setFullscreenVisibility from '@libs/actions/setFullscreenVisibility'; import {READ_COMMANDS} from '@libs/API/types'; @@ -54,7 +56,9 @@ import '@src/libs/subscribeToFullReconnect'; import type {RouteProp} from '@react-navigation/native'; import type {StackCardInterpolationProps} from '@react-navigation/stack'; +import {PortalHost} from '@gorhom/portal'; import React, {useEffect} from 'react'; +import {StyleSheet, View} from 'react-native'; import attachmentModalScreenOptions from './attachmentModalScreenOptions'; import AuthScreensInitHandler from './AuthScreensInitHandler'; @@ -131,6 +135,7 @@ const modalScreenListenersWithCancelSearch = { function AuthScreens() { const theme = useTheme(); + const themeStyles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const rootNavigatorScreenOptions = useRootNavigatorScreenOptions(); @@ -415,6 +420,12 @@ function AuthScreens() { /> + + + diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts b/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts new file mode 100644 index 000000000000..fff118fd189a --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsLiftContext.ts @@ -0,0 +1,19 @@ +import type {CardAnimationContext} from '@react-navigation/stack'; +import type {ContextType} from 'react'; + +import {createContext} from 'react'; + +type TestToolsLift = { + /** Move the focused screen into the portal host above the required-2FA overlay. */ + shouldLift: boolean; + + /** Card animation of the root test-tools modal, captured before the nested stack replaces it. */ + cardAnimation: ContextType; +}; + +const TestToolsLiftContext = createContext({ + shouldLift: false, + cardAnimation: undefined, +}); + +export default TestToolsLiftContext; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx index edaf758e3603..8ff48bf95eae 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator.tsx @@ -6,11 +6,15 @@ import TestToolsServerPage from '@components/TestToolsServerPage'; import useIsAuthenticated from '@hooks/useIsAuthenticated'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useRequire2FAOverlayVisibility, {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSafeAreaInsets from '@hooks/useSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; import blurActiveElement from '@libs/Accessibility/blurActiveElement'; +import shouldLiftTestToolsAboveRequire2FA from '@libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA'; import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {TestToolsModalModalNavigatorParamList} from '@libs/Navigation/types'; import toggleTestToolsModal from '@userActions/TestTool'; @@ -18,20 +22,39 @@ import toggleTestToolsModal from '@userActions/TestTool'; import CONST from '@src/CONST'; import SCREENS from '@src/SCREENS'; -import type {ComponentRef, MouseEvent} from 'react'; +import type {ComponentRef, ComponentType, MouseEvent, ReactNode} from 'react'; -import React, {useCallback, useRef} from 'react'; -import {View} from 'react-native'; +import {Portal} from '@gorhom/portal'; +import {NavigationContext, NavigationRouteContext, useIsFocused} from '@react-navigation/native'; +import {CardAnimationContext} from '@react-navigation/stack'; +import React, {useCallback, useContext, useMemo, useRef} from 'react'; +import {StyleSheet, View} from 'react-native'; import Overlay from './Overlay'; +import TestToolsLiftContext from './TestToolsLiftContext'; +import TestToolsPortalBackdrop from './TestToolsPortalBackdrop'; const Stack = createPlatformStackNavigator(); -function TestToolsModalNavigator() { +type TestToolsRouteName = keyof TestToolsModalModalNavigatorParamList; + +type TestToolsModalFrameProps = { + children: ReactNode; + + /** Keep the stack in this same slot, without painting the sheet. Used while the focused screen is portaled. */ + isPassThrough?: boolean; + + /** This frame is the copy above the required-2FA overlay, so it needs its own dim and safe-area inset. */ + isPortaled?: boolean; +}; + +function TestToolsModalFrame({children, isPassThrough = false, isPortaled = false}: TestToolsModalFrameProps) { const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {left, right} = useSafeAreaInsets(); const outerViewRef = useRef>(null); const isAuthenticated = useIsAuthenticated(); + const showChrome = !isPassThrough; const handleOuterClick = useCallback(() => { // Release focus from any focused element before closing the modal @@ -45,37 +68,112 @@ function TestToolsModalNavigator() { e.stopPropagation(); }, []); + const portaledInsetStyle = useMemo(() => (isPortaled ? {paddingLeft: left, paddingRight: right} : undefined), [isPortaled, left, right]); + + return ( + + + + {isPortaled && } + + + + + true : undefined} + onClick={showChrome ? handleInnerClick : undefined} + pointerEvents={showChrome ? 'auto' : 'box-none'} + style={showChrome ? styles.getTestToolsNavigatorInnerView(shouldUseNarrowLayout, isAuthenticated) : styles.flex1} + > + {children} + + + + + + ); +} + +/** + * On web and Android, move the focused screen into the portal host above the required-2FA overlay. + * The nested stack stays in the root navigator so the URL, backTo, and goBack keep working. + * The host is outside that stack, so the frame gets the screen contexts it reads. + * + * iOS stays in place. Moving that screen into the portal host leaves an empty opaque window, + * which covers the tools once the modal transition ends. + */ +function withRequire2FAPortal(Screen: ComponentType>) { + function Require2FAPortaledTestToolsScreen(props: PlatformStackScreenProps) { + const {shouldLift, cardAnimation} = useContext(TestToolsLiftContext); + const navigation = useContext(NavigationContext); + const route = useContext(NavigationRouteContext); + const isFocused = useIsFocused(); + + if (!shouldLift) { + return ; + } + + // A stack keeps the previous screen mounted. Only the focused one should cover the 2FA screen. + if (!isFocused || !navigation || !route) { + return null; + } + + const screenFrame = ( + + + + ); + // The nested screen's card is a push, not the modal fade. Keep the root modal's animation on the dim. + const frame = cardAnimation ? {screenFrame} : screenFrame; + + return ( + + + {frame} + + + ); + } + + return Require2FAPortaledTestToolsScreen; +} + +const PortaledTestToolsModalPage = withRequire2FAPortal(TestToolsModalPage); +const PortaledTestToolsServerPage = withRequire2FAPortal(TestToolsServerPage); + +function TestToolsModalNavigator() { + const {isRequire2FAOverlayVisible} = useRequire2FAOverlayVisibility(); + const shouldLift = shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible); + const cardAnimation = useContext(CardAnimationContext); + const lift = useMemo(() => ({shouldLift, cardAnimation}), [shouldLift, cardAnimation]); + useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, () => toggleTestToolsModal(), {shouldBubble: false}); return ( - - - - - true} - onClick={handleInnerClick} - style={styles.getTestToolsNavigatorInnerView(shouldUseNarrowLayout, isAuthenticated)} - > - - - - - - - - + + + + + + + + ); } diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx new file mode 100644 index 000000000000..1b8202343beb --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.native.tsx @@ -0,0 +1,23 @@ +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; + +import variables from '@styles/variables'; + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +/** The native Overlay is empty, and the root modal dim sits under the required-2FA screen once this frame is portaled. */ +function TestToolsPortalBackdrop() { + const theme = useTheme(); + const StyleUtils = useStyleUtils(); + + return ( + + ); +} + +export default TestToolsPortalBackdrop; diff --git a/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx new file mode 100644 index 000000000000..91ae1ad3292f --- /dev/null +++ b/src/libs/Navigation/AppNavigator/Navigators/TestToolsPortalBackdrop/index.tsx @@ -0,0 +1,6 @@ +/** Web already dims through Overlay, which follows the root modal card animation. */ +function TestToolsPortalBackdrop() { + return null; +} + +export default TestToolsPortalBackdrop; diff --git a/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts new file mode 100644 index 000000000000..da785c2b5143 --- /dev/null +++ b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ios.ts @@ -0,0 +1,10 @@ +/** iOS already presents this navigator in its own window above the overlay. Moving the screen into the portal host leaves an empty opaque window. */ +function shouldLiftTestToolsAboveRequire2FA( + // The flag is part of the shared signature. iOS never lifts, so the value is unused. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + isRequire2FAOverlayVisible: boolean, +): boolean { + return false; +} + +export default shouldLiftTestToolsAboveRequire2FA; diff --git a/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts new file mode 100644 index 000000000000..e9a8bc9e4e27 --- /dev/null +++ b/src/libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA/index.ts @@ -0,0 +1,6 @@ +/** Web and Android paint this navigator under the required-2FA overlay, so the focused screen moves into the portal host above it. */ +function shouldLiftTestToolsAboveRequire2FA(isRequire2FAOverlayVisible: boolean): boolean { + return isRequire2FAOverlayVisible; +} + +export default shouldLiftTestToolsAboveRequire2FA; diff --git a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx index 2b7f7061b380..cde79225d666 100644 --- a/src/pages/RequireTwoFactorAuthenticationOverlay.tsx +++ b/src/pages/RequireTwoFactorAuthenticationOverlay.tsx @@ -2,18 +2,22 @@ import Button from '@components/Button'; import FocusTrapForModal from '@components/FocusTrap/FocusTrapForModal'; import GpsDraftDetailsRefSync from '@components/GpsDraftDetailsRefSync'; import Icon from '@components/Icon'; +import OfflineIndicator from '@components/OfflineIndicator'; import Text from '@components/Text'; +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useRootNavigationState from '@hooks/useRootNavigationState'; -import useShouldShowRequire2FAPage from '@hooks/useShouldShowRequire2FAPage'; +import useRequire2FAOverlayVisibility from '@hooks/useRequire2FAOverlayVisibility'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSignOut from '@hooks/useSignOut'; +import useTestToolsTapResponder from '@hooks/useTestToolsTapResponder'; import useThemeStyles from '@hooks/useThemeStyles'; import useTwoFactorAuthRoute from '@hooks/useTwoFactorAuthRoute'; -import Navigation, {getDeepestFocusedScreen, isTwoFactorSetupScreen} from '@libs/Navigation/Navigation'; +import Navigation from '@libs/Navigation/Navigation'; import variables from '@styles/variables'; @@ -29,7 +33,6 @@ import type GpsDraftDetails from '@src/types/onyx/GpsDraftDetails'; import type {OnyxCollection} from 'react-native-onyx'; -import {useNavigation} from '@react-navigation/core'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; @@ -49,17 +52,20 @@ const is2FARequiredBecauseOfXeroSelector = (email?: string) => { }; function RequireTwoFactorAuthenticationOverlay() { - const navigation = useNavigation(); - const shouldShowRequire2FAPage = useShouldShowRequire2FAPage(); - const isIn2FASetupFlow = useRootNavigationState((state) => { - // When navigation is not ready yet, use the navigation state from the navigation hook. - const focusedScreen = getDeepestFocusedScreen(state ?? navigation.getState()); - return isTwoFactorSetupScreen(focusedScreen?.name); - }); + const {isRequire2FAOverlayVisible, isTestToolsRouteFocused} = useRequire2FAOverlayVisibility(); const illustrations = useMemoizedLazyIllustrations(['Encryption']); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const testToolsTapResponder = useTestToolsTapResponder(); const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + // OfflineIndicator is a fixed-height row. Safe-area padding has to live on the container around it, + // or the home-indicator inset consumes that height and the "You appear to be offline" label clips. + const offlineBannerSafeAreaStyle = useBottomSafeSafeAreaPaddingStyle({ + addBottomSafeAreaPadding: true, + addOfflineIndicatorBottomSafeAreaPadding: false, + }); const {getTwoFactorAuthRoute} = useTwoFactorAuthRoute(); const {signOut, leaveDelegateAccount, isActingAsDelegate, isTrackingGPS} = useSignOut(); const gpsDraftDetailsRef = useRef(undefined); @@ -90,11 +96,11 @@ function RequireTwoFactorAuthenticationOverlay() { }, [account, onboardingValues, onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath]); useEffect(() => { - if (!shouldShowRequire2FAPage || isIn2FASetupFlow) { + if (!isRequire2FAOverlayVisible) { return; } snapshotOnboardingResumePathIfNeeded(); - }, [shouldShowRequire2FAPage, isIn2FASetupFlow, snapshotOnboardingResumePathIfNeeded]); + }, [isRequire2FAOverlayVisible, snapshotOnboardingResumePathIfNeeded]); const onEscapePress = () => { if (isEscapeInFlight) { @@ -112,17 +118,18 @@ function RequireTwoFactorAuthenticationOverlay() { Navigation.navigate(getTwoFactorAuthRoute(ROUTES.SETTINGS_SECURITY, {forceSetup: true})); }; - if (!shouldShowRequire2FAPage || isIn2FASetupFlow) { + if (!isRequire2FAOverlayVisible) { return null; } return ( <> {isActingAsDelegate && isTrackingGPS && } - + @@ -153,13 +160,21 @@ function RequireTwoFactorAuthenticationOverlay() { variant={CONST.BUTTON_VARIANT.SUCCESS} onPress={enableTwoFactorAuth} > - + {!isTestToolsRouteFocused && } {translate('twoFactorAuth.enable2FA')} + {shouldUseNarrowLayout && isOffline && ( + + + + )} diff --git a/src/styles/index.ts b/src/styles/index.ts index d8648782516e..4643b40ed752 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6239,6 +6239,10 @@ const staticStyles = (theme: ThemeColors) => zIndex: 20, // must be greater than floatingCameraButton.zIndex }, + twoFARequiredTestToolsHost: { + zIndex: 22, // must be greater than twoFARequiredOverlay.zIndex + }, + twoFARequiredContainer: { maxWidth: 520, margin: 'auto', diff --git a/tests/ui/RequireTwoFactorOverlayStackingTest.tsx b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx new file mode 100644 index 000000000000..6523f5800aee --- /dev/null +++ b/tests/ui/RequireTwoFactorOverlayStackingTest.tsx @@ -0,0 +1,478 @@ +// The jest.mock factories below construct React elements and read props off untyped mock values, +// so the unsafe-* rules fire on intentional test wiring rather than real type problems. +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return */ +import {render, screen, within} from '@testing-library/react-native'; + +import {REQUIRE_2FA_ABOVE_PORTAL_HOST} from '@hooks/useRequire2FAOverlayVisibility'; + +import TestToolsModalNavigator from '@libs/Navigation/AppNavigator/Navigators/TestToolsModalNavigator'; + +import RequireTwoFactorAuthenticationOverlay from '@pages/RequireTwoFactorAuthenticationOverlay'; + +import React from 'react'; +import {Platform} from 'react-native'; + +type Visibility = { + isRequire2FAOverlayVisible: boolean; + isTestToolsRouteFocused: boolean; +}; + +let mockVisibility: Visibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: false, +}; +let mockIsOffline = true; +let mockShouldUseNarrowLayout = true; +let testToolsStackMountCount = 0; + +jest.mock('@hooks/useRequire2FAOverlayVisibility', () => ({ + __esModule: true, + REQUIRE_2FA_ABOVE_PORTAL_HOST: 'require2FAAbove', + default: () => mockVisibility, +})); + +jest.mock('@hooks/useNetwork', () => ({ + __esModule: true, + default: () => ({isOffline: mockIsOffline}), +})); + +jest.mock('@hooks/useBottomSafeSafeAreaPaddingStyle', () => ({ + __esModule: true, + default: () => ({paddingBottom: 34}), +})); + +jest.mock('@hooks/useOnyx', () => ({ + __esModule: true, + default: () => [undefined, {status: 'loaded'}], +})); + +jest.mock('@hooks/useSignOut', () => ({ + __esModule: true, + default: () => ({ + signOut: jest.fn(() => Promise.resolve()), + leaveDelegateAccount: jest.fn(() => Promise.resolve()), + isActingAsDelegate: false, + isTrackingGPS: false, + }), +})); + +jest.mock('@hooks/useLocalize', () => ({ + __esModule: true, + default: () => ({ + translate: (key: string) => (key === 'common.youAppearToBeOffline' ? 'You appear to be offline.' : key), + localeCompare: (a: string, b: string) => a.localeCompare(b), + }), +})); + +jest.mock('@hooks/useTheme', () => ({ + __esModule: true, + default: () => ({icon: '#000', overlay: '#000000'}), +})); + +jest.mock('@hooks/useThemeStyles', () => ({ + __esModule: true, + default: () => + new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === 'getTestToolsNavigatorOuterView' || prop === 'getTestToolsNavigatorInnerView') { + return () => ({}); + } + return {}; + }, + }, + ), +})); + +jest.mock('@hooks/useLazyAsset', () => ({ + useMemoizedLazyIllustrations: () => ({Encryption: 'encryption'}), + useMemoizedLazyExpensifyIcons: () => ({OfflineCloud: 'offline'}), +})); + +jest.mock('@hooks/useTwoFactorAuthRoute', () => ({ + __esModule: true, + default: () => ({getTwoFactorAuthRoute: jest.fn(() => '/settings/security/two-factor-auth')}), +})); + +jest.mock('@hooks/useKeyboardShortcut', () => ({ + __esModule: true, + default: () => {}, +})); + +jest.mock('@hooks/useResponsiveLayout', () => ({ + __esModule: true, + default: () => ({shouldUseNarrowLayout: mockShouldUseNarrowLayout}), +})); + +jest.mock('@hooks/useSafeAreaInsets', () => ({ + __esModule: true, + default: () => ({top: 0, right: 0, bottom: 0, left: 0}), +})); + +// Jest resolves the iOS helper, which never lifts. This follows Platform.OS at call time so one suite can cover web and Android too. +jest.mock('@libs/Navigation/helpers/shouldLiftTestToolsAboveRequire2FA', () => { + const {Platform: RNPlatform} = require('react-native'); + + return { + __esModule: true, + default: (isRequire2FAOverlayVisible: boolean) => isRequire2FAOverlayVisible && RNPlatform.OS !== 'ios', + }; +}); + +jest.mock('@hooks/useIsAuthenticated', () => ({ + __esModule: true, + default: () => true, +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + getActiveRoute: () => '/home', + navigate: jest.fn(), + }, +})); + +jest.mock('@userActions/Welcome', () => ({ + updateOnboardingLastVisitedPath: jest.fn(), +})); + +jest.mock('@userActions/Welcome/OnboardingFlow', () => ({ + buildOnboardingFlowParams: jest.fn(() => ({})), + getRequired2FAOnboardingResumePath: jest.fn(() => ''), +})); + +jest.mock('@userActions/TestTool', () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock('@components/FocusTrap/FocusTrapForModal', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + + return { + __esModule: true, + default: ({active, children}: {active?: boolean; children: React.ReactNode}) => + ReactActual.createElement(View, {testID: active ? 'focus-trap-active' : 'focus-trap-inactive'}, children), + }; +}); + +jest.mock('@components/FocusTrap/FocusTrapForScreen', () => { + return { + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, + }; +}); + +jest.mock('@components/Icon', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + + return { + __esModule: true, + default: () => ReactActual.createElement(View), + }; +}); + +jest.mock('@components/Button/primitives/ButtonKeyboardShortcut', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + + return { + __esModule: true, + default: () => ReactActual.createElement(View, {testID: 'enable-2fa-enter-shortcut'}), + }; +}); + +let mockFocusedRouteName = 'TestToolsModal_Root'; + +jest.mock('@gorhom/portal', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + const {NavigationContext, NavigationRouteContext} = require('@react-navigation/native'); + const {CardAnimationContext} = require('@react-navigation/stack'); + + // A real portal renders at the host, outside the screen's providers. Drop those contexts so the test fails unless the screen puts them back. + return { + Portal: ({hostName, children}: {hostName: string; children: React.ReactNode}) => + ReactActual.createElement( + View, + {testID: `portal-${hostName}`}, + ReactActual.createElement( + CardAnimationContext.Provider, + {value: undefined}, + ReactActual.createElement(NavigationContext.Provider, {value: undefined}, ReactActual.createElement(NavigationRouteContext.Provider, {value: undefined}, children)), + ), + ), + PortalHost: () => null, + }; +}); + +jest.mock('@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator', () => { + const ReactActual = require('react'); + const {View} = require('react-native'); + const {NavigationContext, NavigationRouteContext} = require('@react-navigation/native'); + + function Navigator({children}: {children: React.ReactNode}) { + ReactActual.useEffect(() => { + testToolsStackMountCount += 1; + }, []); + return ReactActual.createElement(View, {testID: 'test-tools-stack'}, children); + } + + function Screen({name, component: Component}: {name: string; component: React.ComponentType<{route: {key: string; name: string}; navigation: {isFocused: () => boolean}}>}) { + const navigation = { + isFocused: () => name === mockFocusedRouteName, + addListener: () => () => {}, + }; + const route = {key: `${name}-key`, name}; + return ReactActual.createElement( + NavigationContext.Provider, + {value: navigation}, + ReactActual.createElement(NavigationRouteContext.Provider, {value: route}, ReactActual.createElement(Component, {route, navigation})), + ); + } + + return { + __esModule: true, + default: () => ({Navigator, Screen}), + }; +}); + +jest.mock('@components/TestToolsModalPage', () => { + const ReactActual = require('react'); + const {Text} = require('react-native'); + const {useRoute} = require('@react-navigation/native'); + + return function MockTestToolsModalPage() { + const route = useRoute(); + return ReactActual.createElement(Text, {testID: 'test-tools-page'}, route.name); + }; +}); + +jest.mock('@components/TestToolsServerPage', () => { + const ReactActual = require('react'); + const {Text} = require('react-native'); + const {useRoute} = require('@react-navigation/native'); + + return function MockTestToolsServerPage() { + const route = useRoute(); + return ReactActual.createElement(Text, {testID: 'test-tools-server-page'}, route.name); + }; +}); + +describe('RequireTwoFactorAuthenticationOverlay stacking', () => { + beforeEach(() => { + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: false, + }; + mockIsOffline = true; + mockShouldUseNarrowLayout = true; + }); + + it('shows the offline banner inside the overlay on a narrow screen and opens test tools from a four-finger tap', () => { + // Given required 2FA is visible on a phone and the user is offline. The overlay covers the ScreenWrapper banner and its four-finger handler. + + // When the overlay renders its own banner in the column, and the user taps with four fingers. + render(); + + // Then the banner is visible without covering the buttons, and the overlay itself handles the four-finger tap. + const overlay = screen.getByTestId('RequireTwoFactorAuthenticationOverlay'); + expect(overlay).toBeOnTheScreen(); + expect(within(overlay).getByTestId('RequireTwoFactorOfflineBanner')).toBeOnTheScreen(); + expect(within(overlay).getByText('You appear to be offline.')).toBeOnTheScreen(); + const fourTouches = {nativeEvent: {touches: [{}, {}, {}, {}]}, touchHistory: {numberActiveTouches: 4}}; + const oneTouch = {nativeEvent: {touches: [{}]}, touchHistory: {numberActiveTouches: 1}}; + expect(overlay.props.onStartShouldSetResponderCapture(fourTouches)).toBe(true); + expect(overlay.props.onStartShouldSetResponderCapture(oneTouch)).toBe(false); + overlay.props.onResponderRelease({touchHistory: {numberActiveTouches: 0}}); + expect(jest.requireMock('@userActions/TestTool').default).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('focus-trap-active')).toBeOnTheScreen(); + expect(screen.getByTestId('enable-2fa-enter-shortcut')).toBeOnTheScreen(); + }); + + it('hides the offline banner on a wide layout', () => { + // Given required 2FA is visible on a wide screen, where ScreenWrapper does not show an offline indicator. + mockShouldUseNarrowLayout = false; + + // When the overlay renders. + render(); + + // Then it does not add a full-width banner of its own. + expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); + expect(screen.queryByTestId('RequireTwoFactorOfflineBanner')).toBeNull(); + }); + + it('hides the banner when the overlay is not showing', () => { + // Given required 2FA is not showing, so the ScreenWrapper banner is visible again. + mockVisibility = { + isRequire2FAOverlayVisible: false, + isTestToolsRouteFocused: false, + }; + + // When the overlay decides whether to render. + render(); + + // Then there is no second banner, because the two would stack. + expect(screen.queryByText('You appear to be offline.')).toBeNull(); + expect(screen.queryByTestId('RequireTwoFactorAuthenticationOverlay')).toBeNull(); + }); + + it('hides the banner while online', () => { + // Given required 2FA is visible but the user is online. + mockIsOffline = false; + + // When the overlay renders. + render(); + + // Then the extra banner stays hidden, because there is no offline state to show. + expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); + expect(screen.queryByText('You appear to be offline.')).toBeNull(); + }); + + it('turns the web focus trap off while test tools are focused', () => { + // Given test tools are focused on top of the required-2FA overlay. + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + // When the overlay renders. + render(); + + // Then the web focus trap turns off, so keyboard focus can reach the tools, and Enter no longer starts 2FA setup. + expect(screen.getByTestId('RequireTwoFactorAuthenticationOverlay')).toBeOnTheScreen(); + expect(screen.getByTestId('focus-trap-inactive')).toBeOnTheScreen(); + expect(screen.queryByTestId('focus-trap-active')).toBeNull(); + expect(screen.queryByTestId('enable-2fa-enter-shortcut')).toBeNull(); + }); +}); + +describe('TestToolsModalNavigator portal', () => { + const originalOS = Platform.OS; + + beforeEach(() => { + mockVisibility = { + isRequire2FAOverlayVisible: false, + isTestToolsRouteFocused: false, + }; + mockFocusedRouteName = 'TestToolsModal_Root'; + mockShouldUseNarrowLayout = true; + testToolsStackMountCount = 0; + Platform.OS = originalOS; + }); + + afterEach(() => { + Platform.OS = originalOS; + }); + + it('keeps test tools in place when the required-2FA overlay is hidden', () => { + // Given the required-2FA overlay is hidden, so the screen contentStyle already dims the tools. + + // When test tools render in the root stack. + render(); + + // Then they stay in place, because nothing is covering them. + expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); + expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen(); + expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull(); + }); + + it('portals the focused screen above the overlay on web and keeps its route', () => { + // Given web, where the overlay is painted after the navigator and would cover an in-place screen. + Platform.OS = 'web'; + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + // When test tools render above that overlay. + render(); + + // Then that screen is portaled with its route. Web dims via Overlay inside the frame. + const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); + + expect(screen.getByTestId('test-tools-stack')).toBeOnTheScreen(); + expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen(); + expect(screen.queryByTestId('test-tools-server-page')).toBeNull(); + }); + + it('renders nothing for an unfocused test tools screen on web', () => { + // Given the server page is the focused test-tools screen while required 2FA is showing on web. + Platform.OS = 'web'; + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + mockFocusedRouteName = 'TestToolsModal_Server'; + + // When test tools render above the overlay. + render(); + + // Then only the focused page is portaled, so the previous tool screen does not stay on top of 2FA. + const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); + + expect(within(portal).getByText('TestToolsModal_Server')).toBeOnTheScreen(); + expect(screen.queryByTestId('test-tools-page')).toBeNull(); + }); + + it('portals the focused screen above the overlay on Android', () => { + // Given Android, where a transparent modal stays inside the stack and the overlay would cover an in-place screen. + Platform.OS = 'android'; + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + // When test tools render above that overlay. + render(); + + // Then that screen is portaled with its route, the same as on web. + const portal = screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`); + + expect(within(portal).getByText('TestToolsModal_Root')).toBeOnTheScreen(); + expect(within(portal).getByTestId('test-tools-portal-backdrop')).toBeOnTheScreen(); + }); + + it('keeps the stack mounted when required 2FA appears while test tools are open', () => { + // Given test tools are already open on web, before the required-2FA flag arrives. + Platform.OS = 'web'; + mockVisibility = { + isRequire2FAOverlayVisible: false, + isTestToolsRouteFocused: true, + }; + const view = render(); + const mountsAfterOpen = testToolsStackMountCount; + + // When required 2FA becomes visible. + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + view.rerender(); + + // Then the stack stays mounted and the focused screen moves into the portal. + expect(testToolsStackMountCount).toBe(mountsAfterOpen); + expect(screen.getByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeOnTheScreen(); + }); + + it('keeps test tools inside the iOS modal while required 2FA is showing', () => { + // Given iOS, where this navigator is already a window above the overlay. + // Moving the screen out of that window into the portal host leaves an empty opaque screen that covers the tools after the transition. + Platform.OS = 'ios'; + mockVisibility = { + isRequire2FAOverlayVisible: true, + isTestToolsRouteFocused: true, + }; + + // When test tools render. + render(); + + // Then they stay in the navigator, where the modal can show them, and nothing is portaled behind it. + expect(screen.queryByTestId(`portal-${REQUIRE_2FA_ABOVE_PORTAL_HOST}`)).toBeNull(); + expect(screen.queryByTestId('test-tools-portal-backdrop')).toBeNull(); + expect(screen.getByTestId('test-tools-page')).toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts new file mode 100644 index 000000000000..0f2e332bc3e3 --- /dev/null +++ b/tests/unit/hooks/useRequire2FAOverlayVisibility.test.ts @@ -0,0 +1,151 @@ +import {renderHook} from '@testing-library/react-native'; + +import useRequire2FAOverlayVisibility, {getRequire2FAFocusedScreen, REQUIRE_2FA_FOCUSED_SCREEN} from '@hooks/useRequire2FAOverlayVisibility'; + +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +let mockRootState: NavigationState | undefined; +let mockShouldShowRequire2FAPage = true; +const mockGetState = jest.fn(); + +jest.mock('@react-navigation/core', () => { + // jest.requireActual is typed as any. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const actual = jest.requireActual('@react-navigation/core'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return { + ...actual, + useNavigation: () => ({ + getState: mockGetState, + }), + }; +}); + +jest.mock('@hooks/useShouldShowRequire2FAPage', () => ({ + __esModule: true, + default: () => mockShouldShowRequire2FAPage, +})); + +jest.mock('@hooks/useRootNavigationState', () => ({ + __esModule: true, + default: (selector: (state: NavigationState | undefined) => number) => selector(mockRootState), +})); + +function navigationState(screenName: string): NavigationState { + return { + key: 'root', + index: 0, + routeNames: [screenName], + routes: [{key: `${screenName}-key`, name: screenName}], + type: 'stack', + stale: false, + }; +} + +describe('getRequire2FAFocusedScreen', () => { + it('classifies a 2FA setup screen', () => { + // Given the focused route is the 2FA setup screen the overlay opens. + const screenName = SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT; + + // When the focused screen is classified. + const focusedScreen = getRequire2FAFocusedScreen(screenName); + + // Then it is setup, so the overlay can hide and stop covering the flow it opened. + expect(focusedScreen).toBe(REQUIRE_2FA_FOCUSED_SCREEN.TWO_FACTOR_SETUP); + }); + + it('classifies test tools screens', () => { + // Given the focused route is a test-tools screen rather than setup. + const screenNames = [SCREENS.TEST_TOOLS_MODAL.ROOT, SCREENS.TEST_TOOLS_MODAL.SERVER, NAVIGATORS.TEST_TOOLS_MODAL_NAVIGATOR]; + + // When each of those routes is classified. + const focusedScreens = screenNames.map((screenName) => getRequire2FAFocusedScreen(screenName)); + + // Then they stay distinct from setup, so the overlay can remain up while the tools are open. + expect(focusedScreens).toEqual([REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS, REQUIRE_2FA_FOCUSED_SCREEN.TEST_TOOLS]); + }); + + it('classifies every other screen as other', () => { + // Given a route that is neither setup nor test tools, including a missing route. + const screenNames = [SCREENS.TWO_FACTOR_AUTH.ENABLED, undefined]; + + // When those routes are classified. + const focusedScreens = screenNames.map((screenName) => getRequire2FAFocusedScreen(screenName)); + + // Then they are normal screens, so the overlay follows the required-2FA flag alone. + expect(focusedScreens).toEqual([REQUIRE_2FA_FOCUSED_SCREEN.OTHER, REQUIRE_2FA_FOCUSED_SCREEN.OTHER]); + }); +}); + +describe('useRequire2FAOverlayVisibility', () => { + beforeEach(() => { + mockRootState = undefined; + mockShouldShowRequire2FAPage = true; + mockGetState.mockReset(); + mockGetState.mockReturnValue(undefined); + }); + + it('shows the overlay when required 2FA is on and the user is not in setup', () => { + // Given required 2FA is on and the user is on Home, not in setup. + mockRootState = navigationState(SCREENS.HOME); + + // When the visibility hook runs. + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + // Then the overlay shows, because the user still has to enable 2FA. + expect(result.current.isRequire2FAOverlayVisible).toBe(true); + expect(result.current.isTestToolsRouteFocused).toBe(false); + }); + + it('hides the overlay during 2FA setup', () => { + // Given the user is on the 2FA setup screen the overlay opened. + mockRootState = navigationState(SCREENS.TWO_FACTOR_AUTH.DYNAMIC_ROOT); + + // When the visibility hook runs. + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + // Then the overlay hides, so it does not cover that setup flow. + expect(result.current.isRequire2FAOverlayVisible).toBe(false); + expect(result.current.isTestToolsRouteFocused).toBe(false); + }); + + it('hides the overlay when required 2FA is off', () => { + // Given required 2FA is turned off. + mockShouldShowRequire2FAPage = false; + mockRootState = navigationState(SCREENS.HOME); + + // When the visibility hook runs. + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + // Then the overlay hides, because there is nothing left to block. + expect(result.current.isRequire2FAOverlayVisible).toBe(false); + }); + + it('keeps the overlay visible while test tools are focused', () => { + // Given required 2FA is on and test tools are the focused screen. + mockRootState = navigationState(SCREENS.TEST_TOOLS_MODAL.ROOT); + + // When the visibility hook runs. + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + // Then the overlay stays visible, so closing the tools returns to the 2FA screen instead of the app. + expect(result.current.isRequire2FAOverlayVisible).toBe(true); + expect(result.current.isTestToolsRouteFocused).toBe(true); + }); + + it('falls back to the navigation hook when root state is missing', () => { + // Given the root navigation state is missing and the navigation hook still reports test tools. + mockGetState.mockReturnValue(navigationState(SCREENS.TEST_TOOLS_MODAL.SERVER)); + + // When the visibility hook runs. + const {result} = renderHook(() => useRequire2FAOverlayVisibility()); + + // Then it uses that hook, so the overlay does not flicker off before the root state is ready. + expect(result.current.isTestToolsRouteFocused).toBe(true); + expect(result.current.isRequire2FAOverlayVisible).toBe(true); + }); +});