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
30 changes: 29 additions & 1 deletion src/components/MultifactorAuthentication/mfaNavigation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {createNavigationContainerRef, StackActions} from '@react-navigation/native';
import type {MultifactorAuthenticationModalNavigatorParamList} from '@libs/Navigation/types';
import CONFIG from '@src/CONFIG';
import SCREENS from '@src/SCREENS';

/**
* Internal placeholder used only as a mount-time buffer inside this module.
Expand All @@ -14,6 +16,22 @@ type MultifactorAuthenticationModalNavigatorInternalParamList = MultifactorAuthe

const mfaNavigationRef = createNavigationContainerRef<MultifactorAuthenticationModalNavigatorInternalParamList>();

// Outcome screens are terminal states the flow ends on.
const OUTCOME_SCREENS = new Set<keyof MultifactorAuthenticationModalNavigatorParamList>([
SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS,
SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE,
]);

// Screens that live inside this independent overlay navigator. REVOKE and AUTHORIZE_TRANSACTION are intentionally excluded: they
// render in the main RHP modal stack, not this tree.
const MFA_OVERLAY_SCREENS = new Set<string>([
MFA_INITIAL_SCREEN,
SCREENS.MULTIFACTOR_AUTHENTICATION.MAGIC_CODE,
SCREENS.MULTIFACTOR_AUTHENTICATION.PROMPT,
SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_SUCCESS,
SCREENS.MULTIFACTOR_AUTHENTICATION.OUTCOME_FAILURE,
]);

let pendingNavigation: {screen: string; params?: Record<string, unknown>} | undefined;

/**
Expand Down Expand Up @@ -53,6 +71,16 @@ function navigate<T extends keyof MultifactorAuthenticationModalNavigatorParamLi
return;
}

// In HybridApp the MFA overlay renders in an independent BaseNavigationContainer presented inside a native
// modal, where StackActions.replace silently fails to commit (the computed state is never applied), stranding
// the user on the spinner-bearing Prompt screen. For the terminal outcome screens we push instead, which
// commits reliably; outcome screens end the flow (the stack is reset when the overlay closes) so the extra
// stack entry has no downside. Standalone keeps replace, where it works as expected.
if (CONFIG.IS_HYBRID_APP && OUTCOME_SCREENS.has(screen)) {
mfaNavigationRef.dispatch(StackActions.push(screen, params));
return;
}

mfaNavigationRef.dispatch(StackActions.replace(screen, params));
}

Expand All @@ -73,5 +101,5 @@ function resetMfaNavigation() {
hasInitialLaidOut = false;
}

export {MFA_INITIAL_SCREEN, mfaNavigationRef, navigate, handleInitialScreenLayout, resetMfaNavigation};
export {MFA_INITIAL_SCREEN, MFA_OVERLAY_SCREENS, mfaNavigationRef, navigate, handleInitialScreenLayout, resetMfaNavigation};
export type {MultifactorAuthenticationModalNavigatorInternalParamList};
21 changes: 18 additions & 3 deletions src/components/ScreenWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import {useFocusEffect, useIsFocused, useNavigation, usePreventRemove} from '@react-navigation/native';
import {NavigationRouteContext, useFocusEffect, useIsFocused, useNavigation, usePreventRemove} from '@react-navigation/native';
import type {Route} from '@react-navigation/native';
import {isSingleNewDotEntrySelector} from '@selectors/HybridApp';
import type {ReactNode} from 'react';
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import React, {createContext, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import type {StyleProp, View, ViewStyle} from 'react-native';
import {DeviceEventEmitter, Keyboard} from 'react-native';
import type {EdgeInsets} from 'react-native-safe-area-context';
import CustomDevMenu from '@components/CustomDevMenu';
import FocusTrapForScreen from '@components/FocusTrap/FocusTrapForScreen';
import type FocusTrapForScreenProps from '@components/FocusTrap/FocusTrapForScreen/FocusTrapProps';
import {useInitialURLState} from '@components/InitialURLContextProvider';
import {MFA_OVERLAY_SCREENS} from '@components/MultifactorAuthentication/mfaNavigation';
import withNavigationFallback from '@components/withNavigationFallback';
import useAccessibilityFocus from '@hooks/useAccessibilityFocus';
import useEnvironment from '@hooks/useEnvironment';
Expand Down Expand Up @@ -38,6 +40,8 @@ import type {ScreenWrapperOfflineIndicatorsProps} from './ScreenWrapperOfflineIn
import ScreenWrapperOfflineIndicators from './ScreenWrapperOfflineIndicators';
import ScreenWrapperStatusContext from './ScreenWrapperStatusContext';

const FallbackRouteContext = createContext<Route<string> | undefined>(undefined);

type ScreenWrapperChildrenProps = {
insets: EdgeInsets;
safeAreaPaddingBottomStyle?: {
Expand Down Expand Up @@ -190,10 +194,21 @@ function ScreenWrapper({
const initialURLWithoutParams = initialURL?.replaceAll(/\?.*/g, '');
const doesInitialURLMatchActiveRoute = !!initialURLWithoutParams?.endsWith(Navigation.getActiveRouteWithoutParams() || initialActiveRouteWithoutParams);

usePreventRemove(isSingleNewDotEntry && doesInitialURLMatchActiveRoute && !shouldBlockSingleEntryOldAppExit, () => {
// A multifactor authentication flow (e.g. Face ID to reveal UK/EU card details) renders in an independent navigation tree
// overlaid above the single NewDot entry, and each MFA screen renders its own ScreenWrapper. Navigation.getActiveRouteWithoutParams()
// still reports the underlying NewDot route while the overlay is up, so doesInitialURLMatchActiveRoute is true on the MFA screens too.
// usePreventRemove calls e.preventDefault() unconditionally whenever the guard is active, so guarding these instances would consume the
// MFA navigator's own stack actions (e.g. replacing the magic-code page with the Face ID prompt, or popping the outcome screen on close),
// blocking transitions within the flow. Guard only the outer NewDot ScreenWrapper, never the ScreenWrappers inside the MFA overlay.
// NavigationRouteContext is undefined when tests mock @react-navigation/native without re-exporting it, so fall back to a noop context to keep useContext valid.
const route = useContext(NavigationRouteContext ?? FallbackRouteContext);
const isMfaOverlayScreen = !!route && MFA_OVERLAY_SCREENS.has(route.name);

usePreventRemove(isSingleNewDotEntry && doesInitialURLMatchActiveRoute && !shouldBlockSingleEntryOldAppExit && !isMfaOverlayScreen, () => {
if (!CONFIG.IS_HYBRID_APP) {
return;
}

closeReactNativeApp({shouldSetNVP: false, isTrackingGPS: false});
});

Expand Down
Loading