diff --git a/src/Expensify.tsx b/src/Expensify.tsx index be371c56f0cc..2b5a5d892640 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -117,9 +117,6 @@ function Expensify() { const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {canBeMissing: true}); const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST, {canBeMissing: true}); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); - const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); - const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); - const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); @@ -348,16 +345,7 @@ function Expensify() { if (introSelected === undefined) { Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url}); } - openReportFromDeepLink( - url, - currentOnboardingPurposeSelected, - currentOnboardingCompanySize, - onboardingInitialPath, - allReports, - isAuthenticated, - introSelected, - conciergeReportID, - ); + openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected); } else { Report.doneCheckingPublicRoom(); } @@ -374,16 +362,7 @@ function Expensify() { Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url}); } const isCurrentlyAuthenticated = hasAuthToken(); - openReportFromDeepLink( - state.url, - currentOnboardingPurposeSelected, - currentOnboardingCompanySize, - onboardingInitialPath, - allReports, - isCurrentlyAuthenticated, - introSelected, - conciergeReportID, - ); + openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected); }); return () => { diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index 5b3009214593..7d32c505a164 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -1,10 +1,8 @@ import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding'; import {emailSelector} from '@selectors/Session'; -import {useEffect, useMemo, useRef} from 'react'; +import {useEffect, useMemo} from 'react'; import {InteractionManager} from 'react-native'; -import {startOnboardingFlow} from '@libs/actions/Welcome/OnboardingFlow'; -import Log from '@libs/Log'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; @@ -29,20 +27,16 @@ function useOnboardingFlowRouter() { const [onboardingValues, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { canBeMissing: true, }); - const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); - const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); - const [onboardingInitialPath, onboardingInitialPathMetadata] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); - const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const isOnboardingLoading = isLoadingOnyxValue(onboardingInitialPathMetadata, accountMetadata); + const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true, selector: emailSelector}); const isLoggingInAsNewSessionUser = isLoggingInAsNewUser(currentUrl, sessionEmail); - const startedOnboardingFlowRef = useRef(false); const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, { selector: tryNewDotOnyxSelector, canBeMissing: true, }); const {isHybridAppOnboardingCompleted, hasBeenAddedToNudgeMigration} = tryNewDot ?? {}; + const isOnboardingLoading = isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotMetadata); const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); @@ -56,7 +50,7 @@ function useOnboardingFlowRouter() { // This should delay opening the onboarding modal so it does not interfere with the ongoing ReportScreen params changes // eslint-disable-next-line @typescript-eslint/no-deprecated const handle = InteractionManager.runAfterInteractions(() => { - // Prevent starting the onboarding flow if we are logging in as a new user with short lived token + // Prevent showing onboarding if we are logging in as a new user with short lived token if (currentUrl?.includes(ROUTES.TRANSITION_BETWEEN_APPS) && isLoggingInAsNewSessionUser) { return; } @@ -89,12 +83,6 @@ function useOnboardingFlowRouter() { return; } - if (hasBeenAddedToNudgeMigration) { - return; - } - - const isOnboardingCompleted = hasCompletedGuidedSetupFlowSelector(onboardingValues) && onboardingValues?.testDriveModalDismissed !== false; - if (CONFIG.IS_HYBRID_APP) { // For single entries, such as using the Travel feature from OldDot, we don't want to show onboarding if (isSingleNewDotEntry) { @@ -105,37 +93,6 @@ function useOnboardingFlowRouter() { if (isHybridAppOnboardingCompleted === false) { Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT); } - - // But if the hybrid app onboarding is completed, but NewDot onboarding is not completed, we start NewDot onboarding flow - // This is a special case when user created an account from NewDot without finishing the onboarding flow and then logged in from OldDot - if (isHybridAppOnboardingCompleted === true && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) { - startedOnboardingFlowRef.current = true; - Log.info('[Onboarding] Hybrid app onboarding is completed, but NewDot onboarding is not completed, starting NewDot onboarding flow'); - startOnboardingFlow({ - onboardingValuesParam: onboardingValues, - isUserFromPublicDomain: !!account?.isFromPublicDomain, - hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, - currentOnboardingCompanySize, - currentOnboardingPurposeSelected, - onboardingInitialPath, - onboardingValues, - }); - } - } - - // If the user is not transitioning from OldDot to NewDot, we should start NewDot onboarding flow if it's not completed yet - if (!CONFIG.IS_HYBRID_APP && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) { - startedOnboardingFlowRef.current = true; - Log.info('[Onboarding] Not a hybrid app, NewDot onboarding is not completed, starting NewDot onboarding flow'); - startOnboardingFlow({ - onboardingValuesParam: onboardingValues, - isUserFromPublicDomain: !!account?.isFromPublicDomain, - hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, - currentOnboardingCompanySize, - currentOnboardingPurposeSelected, - onboardingInitialPath, - onboardingValues, - }); } }); @@ -152,16 +109,11 @@ function useOnboardingFlowRouter() { hasBeenAddedToNudgeMigration, dismissedProductTrainingMetadata, dismissedProductTraining?.migratedUserWelcomeModal, - onboardingValues, dismissedProductTraining, - account?.isFromPublicDomain, - account?.hasAccessibleDomainPolicies, currentUrl, isLoggingInAsNewSessionUser, - currentOnboardingCompanySize, - currentOnboardingPurposeSelected, - onboardingInitialPath, isOnboardingLoading, + onboardingValues, ]); return { diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts index 651bc15f14b1..8eceff88a361 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts @@ -1,9 +1,11 @@ -import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; -import {findFocusedRoute, StackRouter} from '@react-navigation/native'; +import {CommonActions, StackRouter} from '@react-navigation/native'; +import type {RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; import type {ParamListBase} from '@react-navigation/routers'; -import {isFullScreenName, isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; +import {createGuardContext, evaluateGuards} from '@libs/Navigation/guards'; +import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; -import * as Welcome from '@userActions/Welcome'; +import {linkingConfig} from '@libs/Navigation/linkingConfig'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import { @@ -56,20 +58,45 @@ function isPreloadAction(action: RootStackNavigatorAction): action is PreloadAct return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD; } -function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) { - if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { - return false; +/** + * Evaluates navigation guards and handles BLOCK/REDIRECT results + * + * @param state - Current navigation state + * @param action - Navigation action being attempted + * @param configOptions - Router configuration options + * @param stackRouter - Stack router instance + * @returns Modified state if guard blocks/redirects, null if navigation should proceed + */ +function handleNavigationGuards( + state: StackNavigationState, + action: RootStackNavigatorAction, + configOptions: RouterConfigOptions, + stackRouter: ReturnType, +): ReturnType['getStateForAction']> | null { + const guardContext = createGuardContext(); + const guardResult = evaluateGuards(state, action, guardContext); + + if (guardResult.type === 'BLOCK') { + syncBrowserHistory(state); + return state; } - const currentFocusedRoute = findFocusedRoute(state); - const targetFocusedRoute = findFocusedRoute(action?.payload); - // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen - if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { - Welcome.setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); - return true; + if (guardResult.type === 'REDIRECT') { + const redirectState = getAdaptedStateFromPath(guardResult.route, linkingConfig.config); + + if (!redirectState || !redirectState.routes) { + return null; + } + + const resetAction = CommonActions.reset({ + index: redirectState.index ?? redirectState.routes.length - 1, + routes: redirectState.routes, + }); + + return stackRouter.getStateForAction(state, resetAction, configOptions); } - return false; + return null; } function isNavigatingToModalFromModal(state: StackNavigationState, action: CommonActions.Action | StackActionType): action is PushActionType { @@ -90,6 +117,14 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) { return { ...stackRouter, getStateForAction(state: StackNavigationState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) { + // Evaluate navigation guards FIRST + const guardState = handleNavigationGuards(state, action, configOptions, stackRouter); + if (guardState) { + return guardState; + } + + // Guards allowed navigation - continue with routing logic + if (isPreloadAction(action) && action.payload.name === state.routes.at(-1)?.name) { return state; } @@ -121,12 +156,6 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) { return handlePushFullscreenAction(state, action, configOptions, stackRouter); } - // Don't let the user navigate back to a non-onboarding screen if they are currently on an onboarding screen and it's not finished. - if (shouldPreventReset(state, action)) { - syncBrowserHistory(state); - return state; - } - if (isNavigatingToModalFromModal(state, action)) { return handleNavigatingToModalFromModal(state, action, configOptions, stackRouter); } diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 73151129f38d..04f5fdfff7a4 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -1,8 +1,7 @@ import type {NavigationState} from '@react-navigation/native'; import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native'; -import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding'; +import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; -import {useOnboardingValues} from '@components/OnyxListItemProvider'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import {useCurrentReportIDActions} from '@hooks/useCurrentReportID'; import useOnyx from '@hooks/useOnyx'; @@ -17,8 +16,6 @@ import shouldOpenLastVisitedPath from '@libs/shouldOpenLastVisitedPath'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; -import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; -import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import {endSpan, getSpan, startSpan} from '@src/libs/telemetry/activeSpans'; import {navigationIntegration} from '@src/libs/telemetry/integrations'; @@ -102,20 +99,11 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N selector: hasCompletedGuidedSetupFlowSelector, canBeMissing: true, }); - const [wasInvitedToNewDot = false] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { - selector: wasInvitedToNewDotSelector, - canBeMissing: true, - }); - const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY, {canBeMissing: true}); - const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); - const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); - const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); - const onboardingValues = useOnboardingValues(); + const previousAuthenticated = usePrevious(authenticated); const initialState = useMemo(() => { const path = initialUrl ? getPathFromURL(initialUrl) : null; - if (path?.includes(ROUTES.MIGRATED_USER_WELCOME_MODAL.route) && shouldOpenLastVisitedPath(lastVisitedPath) && isOnboardingCompleted && authenticated) { Navigation.isNavigationReady().then(() => { Navigation.navigate(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute()); @@ -136,22 +124,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N return undefined; } - // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. - // We also make sure that the user is authenticated, isn't part of a group workspace, isn't in the transition flow & wasn't invited to NewDot. - if (!CONFIG.IS_HYBRID_APP && !hasNonPersonalPolicy && !isOnboardingCompleted && !wasInvitedToNewDot && authenticated) { - return getAdaptedStateFromPath( - getOnboardingInitialPath({ - isUserFromPublicDomain: !!account.isFromPublicDomain, - hasAccessiblePolicies: !!account.hasAccessibleDomainPolicies, - currentOnboardingPurposeSelected, - currentOnboardingCompanySize, - onboardingInitialPath, - onboardingValues, - }), - linkingConfig.config, - ); - } - if (shouldOpenLastVisitedPath(lastVisitedPath) && authenticated) { // Only skip restoration if there's a specific deep link that's not the root // This allows restoration when app is killed and reopened without a deep link diff --git a/src/libs/Navigation/guards/OnboardingGuard.ts b/src/libs/Navigation/guards/OnboardingGuard.ts new file mode 100644 index 000000000000..e9ffc469b20a --- /dev/null +++ b/src/libs/Navigation/guards/OnboardingGuard.ts @@ -0,0 +1,169 @@ +import type {NavigationAction, NavigationState} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; +import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; +import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import {setOnboardingErrorMessage} from '@libs/actions/Welcome'; +import Log from '@libs/Log'; +import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; +import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; +import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; +import type {Account, Onboarding} from '@src/types/onyx'; +import type {GuardResult, NavigationGuard} from './types'; + +type OnboardingCompanySize = ValueOf; +type OnboardingPurpose = ValueOf; + +/** + * Module-level Onyx subscriptions for OnboardingGuard + * These provide synchronous access to onboarding-related data + */ +let onboarding: OnyxEntry; +let account: OnyxEntry; +let tryNewDot: {isHybridAppOnboardingCompleted: boolean | undefined; hasBeenAddedToNudgeMigration: boolean} | undefined; +let hybridApp: {isSingleNewDotEntry?: boolean} | undefined; +let onboardingPurposeSelected: OnyxEntry; +let onboardingCompanySize: OnyxEntry; +let onboardingInitialPath: OnyxEntry; +let hasNonPersonalPolicy: OnyxEntry; +let wasInvitedToNewDot: boolean | undefined; + +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => { + onboarding = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + account = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_TRY_NEW_DOT, + callback: (value) => { + tryNewDot = value ? tryNewDotOnyxSelector(value) : undefined; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.HYBRID_APP, + callback: (value) => { + hybridApp = {isSingleNewDotEntry: value ? isSingleNewDotEntrySelector(value) : undefined}; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, + callback: (value) => { + onboardingPurposeSelected = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.ONBOARDING_COMPANY_SIZE, + callback: (value) => { + onboardingCompanySize = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, + callback: (value) => { + onboardingInitialPath = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.HAS_NON_PERSONAL_POLICY, + callback: (value) => { + hasNonPersonalPolicy = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_INTRO_SELECTED, + callback: (value) => { + wasInvitedToNewDot = value ? wasInvitedToNewDotSelector(value) : undefined; + }, +}); + +/** + * Helper to get the correct onboarding route based on current progress + */ +function getOnboardingRoute(): Route { + return getOnboardingInitialPath({ + onboardingValuesParam: onboarding, + isUserFromPublicDomain: !!account?.isFromPublicDomain, + hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, + currentOnboardingCompanySize: onboardingCompanySize, + currentOnboardingPurposeSelected: onboardingPurposeSelected, + onboardingInitialPath, + onboardingValues: onboarding, + }) as Route; +} + +function shouldPreventReset(state: NavigationState, action: NavigationAction) { + if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { + return false; + } + + const currentFocusedRoute = findFocusedRoute(state); + const targetFocusedRoute = findFocusedRoute(action?.payload as NavigationState); + + // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen + if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { + setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); + return true; + } + + return false; +} + +/** + * OnboardingGuard handles ONLY the core NewDot onboarding flow + */ +const OnboardingGuard: NavigationGuard = { + name: 'OnboardingGuard', + + evaluate: (state, action, context): GuardResult => { + if (shouldPreventReset(state, action)) { + return {type: 'BLOCK', reason: 'Cannot reset to non-onboarding screen while on onboarding'}; + } + + const isTransitioning = context.currentUrl?.includes(ROUTES.TRANSITION_BETWEEN_APPS); + const isOnboardingCompleted = hasCompletedGuidedSetupFlowSelector(onboarding) ?? false; + const isMigratedUser = tryNewDot?.hasBeenAddedToNudgeMigration ?? false; + const isSingleEntry = hybridApp?.isSingleNewDotEntry ?? false; + const needsExplanationModal = (CONFIG.IS_HYBRID_APP && tryNewDot?.isHybridAppOnboardingCompleted !== true) ?? false; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const isInvitedOrGroupMember = (!CONFIG.IS_HYBRID_APP && (hasNonPersonalPolicy || wasInvitedToNewDot)) ?? false; + + const shouldSkipOnboarding = context.isLoading || isTransitioning || isOnboardingCompleted || isMigratedUser || isSingleEntry || needsExplanationModal || isInvitedOrGroupMember; + + if (shouldSkipOnboarding) { + return {type: 'ALLOW'}; + } + + // User needs onboarding - calculate the correct step and redirect + const onboardingRoute = getOnboardingRoute(); + + Log.info('[OnboardingGuard] Redirecting to onboarding route', false, {onboardingRoute}); + + return { + type: 'REDIRECT', + route: onboardingRoute, + }; + }, +}; + +export default OnboardingGuard; diff --git a/src/libs/Navigation/guards/TestDriveModalGuard.ts b/src/libs/Navigation/guards/TestDriveModalGuard.ts new file mode 100644 index 000000000000..5adb7146824b --- /dev/null +++ b/src/libs/Navigation/guards/TestDriveModalGuard.ts @@ -0,0 +1,163 @@ +import type {NavigationAction, NavigationState} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; +import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import Log from '@libs/Log'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type Onboarding from '@src/types/onyx/Onboarding'; +import type {GuardResult, NavigationGuard} from './types'; + +/** + * Module-level Onyx subscriptions for TestDriveModalGuard + */ +let onboarding: OnyxEntry; +let onboardingPolicyID: OnyxEntry; +let hasNonPersonalPolicy: OnyxEntry; + +let hasRedirectedToTestDriveModal = false; + +/** + * Reset the session flag (for testing purposes) + */ +function resetSessionFlag() { + hasRedirectedToTestDriveModal = false; +} + +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => { + onboarding = value; + + // Reset the session flag when modal is dismissed + if (value?.testDriveModalDismissed === true) { + hasRedirectedToTestDriveModal = false; + } + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.ONBOARDING_POLICY_ID, + callback: (value) => { + onboardingPolicyID = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.HAS_NON_PERSONAL_POLICY, + callback: (value) => { + hasNonPersonalPolicy = value; + }, +}); + +/** + * Check if navigation should be blocked while the test drive modal is active. + * After the guard redirects to the modal, there's a delay before the native Modal overlay becomes visible + * During this window, tab switches can push screens on top of the modal navigator, + * causing DISMISS_MODAL to fail since it only checks the last route. + */ +function shouldBlockWhileModalActive(state: NavigationState, action: NavigationAction): boolean { + const isModalDismissed = onboarding?.testDriveModalDismissed === true; + if (!hasRedirectedToTestDriveModal || isModalDismissed) { + return false; + } + + // Only block when the test drive modal is the LAST route (on top of the stack). + // If something was already pushed on top (broken state), don't block — the user + // needs to be able to navigate to recover from the error. + const lastRoute = state.routes.at(-1); + if (lastRoute?.name !== NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR) { + return false; + } + + // Allow DISMISS_MODAL (Skip button) and GO_BACK (close/X button, confirm flow) + if (action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL || action.type === CONST.NAVIGATION.ACTION_TYPE.GO_BACK) { + return false; + } + + return true; +} + +/** + * Check if we're already on or navigating to the test drive modal + * This prevents redirect loops where our redirect creates new navigation actions + */ +function isNavigatingToTestDriveModal(state: NavigationState, action: NavigationAction): boolean { + const currentRoute = findFocusedRoute(state); + if (currentRoute?.name === SCREENS.TEST_DRIVE_MODAL.ROOT) { + return true; + } + + if (action.type === 'RESET' && action.payload) { + const targetRoute = findFocusedRoute(action.payload as NavigationState); + if (targetRoute?.name === SCREENS.TEST_DRIVE_MODAL.ROOT) { + return true; + } + } + + return false; +} + +/** + * TestDriveModalGuard handles the test drive modal flow + * This modal appears after a user completes the guided setup flow but hasn't dismissed the test drive modal yet + */ +const TestDriveModalGuard: NavigationGuard = { + name: 'TestDriveModalGuard', + + evaluate: (state: NavigationState, action: NavigationAction, context): GuardResult => { + if (context.isLoading) { + return {type: 'ALLOW'}; + } + + if (shouldBlockWhileModalActive(state, action)) { + return {type: 'BLOCK', reason: '[TestDriveModalGuard] Blocking navigation while test drive modal is active'}; + } + + const isModalDismissed = onboarding?.testDriveModalDismissed === true; + const isNavigatingToModal = isNavigatingToTestDriveModal(state, action); + + // Redirect to home if trying to access dismissed test drive modal (prevent URL navigation) + if (isNavigatingToModal && isModalDismissed) { + Log.info('[TestDriveModalGuard] Redirecting to home - test drive modal has been dismissed'); + return {type: 'REDIRECT', route: ROUTES.HOME}; + } + + // Allow if we're already navigating to the modal (prevents redirect loops) + if (isNavigatingToModal) { + return {type: 'ALLOW'}; + } + + // Skip if already redirected or user has accessible policy + if (hasRedirectedToTestDriveModal || (onboardingPolicyID && hasNonPersonalPolicy)) { + Log.info('[TestDriveModalGuard] Already redirected or user has accessible policy, allowing'); + return {type: 'ALLOW'}; + } + + // Check if user has completed the guided setup flow + const hasCompletedGuidedSetup = hasCompletedGuidedSetupFlowSelector(onboarding) ?? false; + + // Check if test drive modal should be shown + const shouldShowTestDriveModal = onboarding?.testDriveModalDismissed === false; + + // If user completed setup and modal should be shown, redirect to it once + if (hasCompletedGuidedSetup && shouldShowTestDriveModal) { + Log.info('[TestDriveModalGuard] Redirecting to test drive modal'); + hasRedirectedToTestDriveModal = true; + + return { + type: 'REDIRECT', + route: ROUTES.TEST_DRIVE_MODAL_ROOT.route, + }; + } + + return {type: 'ALLOW'}; + }, +}; + +export default TestDriveModalGuard; +export {resetSessionFlag}; diff --git a/src/libs/Navigation/guards/index.ts b/src/libs/Navigation/guards/index.ts new file mode 100644 index 000000000000..ef8e8c37b2ab --- /dev/null +++ b/src/libs/Navigation/guards/index.ts @@ -0,0 +1,106 @@ +import type {NavigationAction, NavigationState} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Session} from '@src/types/onyx'; +import OnboardingGuard from './OnboardingGuard'; +import TestDriveModalGuard from './TestDriveModalGuard'; +import type {GuardContext, GuardResult, NavigationGuard} from './types'; + +/** + * Module-level Onyx subscriptions for common guard context values + * These provide synchronous access to shared data used by multiple guards + */ +let session: OnyxEntry; +let isLoadingApp = true; + +Onyx.connectWithoutView({ + key: ONYXKEYS.SESSION, + callback: (value) => { + session = value; + }, +}); + +Onyx.connectWithoutView({ + key: ONYXKEYS.IS_LOADING_APP, + callback: (value) => { + isLoadingApp = value ?? true; + }, +}); + +/** + * Registry of all navigation guards + * Guards are evaluated in the order they are registered + */ +const guards: NavigationGuard[] = []; + +/** + * Registers a navigation guard + */ +function registerGuard(guard: NavigationGuard): void { + guards.push(guard); +} + +/** + * Creates a guard context with common computed values + * Guards access specific Onyx data directly via their own subscriptions for runtime checks, + * + * @param overrides - Optional context overrides (e.g., account, onboarding data from hooks) + * @returns Guard context with common helper flags and optional Onyx data + */ +function createGuardContext(overrides?: Partial): GuardContext { + const isAuthenticated = !!session?.authToken; + const currentUrl = getCurrentUrl(); + const isLoading = isLoadingApp; + + return { + isAuthenticated, + isLoading, + currentUrl, + ...overrides, + }; +} + +/** + * Evaluates all registered guards for the given navigation action + * Evaluation short-circuits on the first BLOCK or REDIRECT result. + * + * - BLOCK: block navigation, return unchanged state + * - REDIRECT: create redirect action and process it + * - ALLOW: continue with normal navigation + */ +function evaluateGuards(state: NavigationState, action: NavigationAction, context: GuardContext): GuardResult { + for (const guard of guards) { + const result = guard.evaluate(state, action, context); + + if (result.type === 'BLOCK' || result.type === 'REDIRECT') { + return result; + } + } + + return {type: 'ALLOW'}; +} + +/** + * Gets all registered guards (useful for testing) + */ +function getRegisteredGuards(): readonly NavigationGuard[] { + return guards; +} + +/** + * Clears all registered guards (useful for testing) + */ +function clearGuards(): void { + guards.length = 0; +} + +// Register guards in order of evaluation +// IMPORTANT: Order matters! Guards evaluate in sequence and short-circuit on BLOCK/REDIRECT + +registerGuard(OnboardingGuard); +registerGuard(TestDriveModalGuard); + +export {registerGuard, createGuardContext, evaluateGuards, getRegisteredGuards, clearGuards}; +export type {NavigationGuard, GuardResult, GuardContext}; diff --git a/src/libs/Navigation/guards/types.ts b/src/libs/Navigation/guards/types.ts new file mode 100644 index 000000000000..914dc1493c69 --- /dev/null +++ b/src/libs/Navigation/guards/types.ts @@ -0,0 +1,40 @@ +import type {NavigationAction, NavigationState} from '@react-navigation/native'; +import type {Route} from '@src/ROUTES'; + +/** + * Result returned by a navigation guard after evaluation + */ +type GuardResult = {type: 'ALLOW'} | {type: 'BLOCK'; reason?: string} | {type: 'REDIRECT'; route: Route}; + +/** + * Context provided to guards during evaluation + */ +type GuardContext = { + /** Whether the user is authenticated */ + isAuthenticated: boolean; + + /** Whether the app is still loading initial data */ + isLoading: boolean; + + /** Current URL (for HybridApp and deep link checks) */ + currentUrl: string; +}; + +/** + * Navigation guard interface + * Guards can intercept navigation actions and allow, block, or redirect them + */ +type NavigationGuard = { + /** Guard name for debugging and logging */ + name: string; + + /** + * Evaluates the navigation action and returns a decision + * - ALLOW: Let navigation proceed normally + * - BLOCK: Prevent navigation and keep current state + * - REDIRECT: Replace the navigation with a redirect to a different route + */ + evaluate(state: NavigationState, action: NavigationAction, context: GuardContext): GuardResult; +}; + +export type {GuardResult, GuardContext, NavigationGuard}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index c09d9aa257c8..8e951001b893 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -9,7 +9,6 @@ import asyncOpenURL from '@libs/asyncOpenURL'; import * as Environment from '@libs/Environment/Environment'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; -import Log from '@libs/Log'; import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom'; @@ -29,12 +28,10 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Account, IntroSelected, Report} from '@src/types/onyx'; +import type {IntroSelected, Report} from '@src/types/onyx'; import {doneCheckingPublicRoom, navigateToConciergeChat, openReport} from './Report'; import {canAnonymousUserAccessRoute, isAnonymousUser, signOutAndRedirectToSignIn, waitForUserSignIn} from './Session'; -import {isOnboardingFlowCompleted, setOnboardingErrorMessage} from './Welcome'; -import {startOnboardingFlow} from './Welcome/OnboardingFlow'; -import type {OnboardingCompanySize, OnboardingPurpose} from './Welcome/OnboardingFlow'; +import {setOnboardingErrorMessage} from './Welcome'; let isNetworkOffline = false; let networkStatus: NetworkStatus; @@ -56,15 +53,6 @@ Onyx.connectWithoutView({ }, }); -let account: OnyxEntry; -// Use connectWithoutView to subscribe to account data without affecting UI -Onyx.connectWithoutView({ - key: ONYXKEYS.ACCOUNT, - callback: (value) => { - account = value; - }, -}); - function buildOldDotURL(url: string, shortLivedAuthToken?: string): Promise { const hashIndex = url.lastIndexOf('#'); const hasHashParams = hashIndex !== -1; @@ -242,16 +230,7 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { openExternalLink(href); } -function openReportFromDeepLink( - url: string, - currentOnboardingPurposeSelected: OnyxEntry, - currentOnboardingCompanySize: OnyxEntry, - onboardingInitialPath: OnyxEntry, - reports: OnyxCollection, - isAuthenticated: boolean, - introSelected: OnyxEntry, - conciergeReportID: string | undefined, -) { +function openReportFromDeepLink(url: string, reports: OnyxCollection, isAuthenticated: boolean, conciergeReportID: string | undefined, introSelected: OnyxEntry) { const reportID = getReportIDFromLink(url); if (reportID && !isAuthenticated) { @@ -387,25 +366,7 @@ function openReportFromDeepLink( if (isAnonymousUser()) { handleDeeplinkNavigation(); - return; } - // We need skip deeplinking if the user hasn't completed the guided setup flow. - isOnboardingFlowCompleted({ - onNotCompleted: () => { - Log.info('[Onboarding] User has not completed the guided setup flow, starting onboarding flow from deep link'); - startOnboardingFlow({ - onboardingValuesParam: val, - hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, - isUserFromPublicDomain: !!account?.isFromPublicDomain, - currentOnboardingPurposeSelected, - currentOnboardingCompanySize, - onboardingInitialPath, - onboardingValues: val, - }); - }, - onCompleted: handleDeeplinkNavigation, - onCanceled: handleDeeplinkNavigation, - }); }); }, }); diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index 9c69f9ed7a68..a10f96e62a8e 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -11,84 +11,21 @@ import type {OnboardingAccounting} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Account, OnboardingPurpose} from '@src/types/onyx'; +import type {OnboardingPurpose} from '@src/types/onyx'; import type Onboarding from '@src/types/onyx/Onboarding'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnboardingCompanySize} from './OnboardingFlow'; -import {startOnboardingFlow} from './OnboardingFlow'; let isLoadingReportData = true; -let onboarding: Onboarding | undefined; -let account: Account | undefined; - -type HasCompletedOnboardingFlowProps = { - onCompleted?: () => void; - onNotCompleted?: () => void; - onCanceled?: () => void; -}; let resolveIsReadyPromise: (value?: Promise) => void | undefined; let isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); -let resolveOnboardingFlowStatus: () => void; -let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { - resolveOnboardingFlowStatus = resolve; -}); - function onServerDataReady(): Promise { return isServerDataReadyPromise; } -let isOnboardingInProgress = false; -function isOnboardingFlowCompleted({onCompleted, onNotCompleted, onCanceled}: HasCompletedOnboardingFlowProps) { - isOnboardingFlowStatusKnownPromise.then(() => { - // Don't trigger onboarding if we are showing the require 2FA page - const shouldShowRequire2FAPage = account && !!account.needsTwoFactorAuthSetup && (!account.requiresTwoFactorAuth || !!account.twoFactorAuthSetupInProgress); - if (shouldShowRequire2FAPage) { - return; - } - - if (isEmptyObject(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { - onCanceled?.(); - return; - } - - // The value `undefined` should not be used here because `testDriveModalDismissed` may not always exist in `onboarding`. - // So we only compare it to `false` to avoid unintentionally opening the test drive modal. - if (onboarding?.testDriveModalDismissed === false) { - Navigation.setNavigationActionToMicrotaskQueue(() => { - // Check if we're already on the test drive modal route or if navigation is in progress to prevent duplicate navigation - const currentRoute = Navigation.getActiveRoute(); - if (currentRoute?.includes(ROUTES.TEST_DRIVE_MODAL_ROOT.route)) { - return; - } - - Log.info('[Onboarding] User has not completed the guided setup flow, starting onboarding flow from test drive modal'); - startOnboardingFlow({ - onboardingInitialPath: ROUTES.TEST_DRIVE_MODAL_ROOT.route, - isUserFromPublicDomain: false, - hasAccessiblePolicies: false, - currentOnboardingCompanySize: undefined, - currentOnboardingPurposeSelected: undefined, - onboardingValues: onboarding, - }); - }); - - return; - } - - if (onboarding?.hasCompletedGuidedSetupFlow) { - isOnboardingInProgress = false; - onCompleted?.(); - } else if (!isOnboardingInProgress) { - isOnboardingInProgress = true; - onNotCompleted?.(); - } - }); -} - /** * Check if report data are loaded */ @@ -100,17 +37,6 @@ function checkServerDataReady() { resolveIsReadyPromise?.(); } -/** - * Check if the onboarding data is loaded - */ -function checkOnboardingDataReady() { - if (onboarding === undefined || account === undefined) { - return; - } - - resolveOnboardingFlowStatus(); -} - function setOnboardingPurposeSelected(value: OnboardingPurpose) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } @@ -186,26 +112,6 @@ function completeHybridAppOnboarding() { }); } -// We use `connectWithoutView` here since this connection only updates a module-level variable -// and doesn't need to trigger component re-renders. -Onyx.connectWithoutView({ - key: ONYXKEYS.ACCOUNT, - callback: (value) => { - account = value; - checkOnboardingDataReady(); - }, -}); - -// We use `connectWithoutView` here since this connection only updates a module-level variable -// and doesn't need to trigger component re-renders. -Onyx.connectWithoutView({ - key: ONYXKEYS.NVP_ONBOARDING, - callback: (value) => { - onboarding = value; - checkOnboardingDataReady(); - }, -}); - // We use `connectWithoutView` here since this connection only to get loading flag // and doesn't need to trigger component re-renders. Onyx.connectWithoutView({ @@ -221,11 +127,7 @@ function resetAllChecks() { isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); - isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { - resolveOnboardingFlowStatus = resolve; - }); isLoadingReportData = true; - isOnboardingInProgress = false; } function setSelfTourViewed(shouldUpdateOnyxDataOnlyLocally = false) { @@ -267,7 +169,6 @@ function dismissProductTraining(elementName: string, isDismissedUsingCloseButton export { onServerDataReady, - isOnboardingFlowCompleted, dismissProductTraining, setOnboardingPurposeSelected, updateOnboardingLastVisitedPath, diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 11ac99e4aedf..cfb8de469374 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -69,20 +69,10 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } else { + // Navigate to home to trigger guard evaluation + Navigation.navigate(ROUTES.HOME); } - - // In this case, we have joined an accessible policy. We would have an onboarding policy, but not an admins chat report. - // We should skip the Test Drive modal in this case since we already have a policy to join. - if (onboardingPolicyID && !onboardingAdminsChatReportID) { - return; - } - - // We're using Navigation.isNavigationReady here because without it, on iOS, - // Navigation.dismissModal runs after Navigation.navigate(ROUTES.TEST_DRIVE_MODAL_ROOT.route) - // And dismisses the modal before it even shows - Navigation.isNavigationReady().then(() => { - Navigation.navigate(ROUTES.TEST_DRIVE_MODAL_ROOT.route); - }); } function navigateAfterOnboardingWithMicrotaskQueue( diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 18fcb3f9f670..cb27b82684cd 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import React, {useCallback, useImperativeHandle, useMemo, useRef} from 'react'; -import {InteractionManager, View} from 'react-native'; +import {View} from 'react-native'; import {ScrollView} from 'react-native-gesture-handler'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -107,11 +107,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro companySize: onboardingCompanySize, }); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - Navigation.navigate(ROUTES.TEST_DRIVE_MODAL_ROOT.route); - }); - return; } diff --git a/tests/unit/Navigation/guards/NavigationGuards.test.ts b/tests/unit/Navigation/guards/NavigationGuards.test.ts new file mode 100644 index 000000000000..6cd41fdb8216 --- /dev/null +++ b/tests/unit/Navigation/guards/NavigationGuards.test.ts @@ -0,0 +1,210 @@ +import type {NavigationState} from '@react-navigation/native'; +import {clearGuards, createGuardContext, evaluateGuards, getRegisteredGuards, registerGuard} from '@libs/Navigation/guards'; +import type {GuardContext, NavigationGuard} from '@libs/Navigation/guards/types'; +import ROUTES from '@src/ROUTES'; + +describe('Navigation Guard System', () => { + beforeEach(() => { + // Clear all guards before each test + clearGuards(); + }); + + describe('registerGuard', () => { + it('should register a guard', () => { + const mockGuard: NavigationGuard = { + name: 'TestGuard', + evaluate: () => ({type: 'ALLOW'}), + }; + + registerGuard(mockGuard); + + expect(getRegisteredGuards()).toHaveLength(1); + expect(getRegisteredGuards().at(0)).toBe(mockGuard); + }); + + it('should register multiple guards in order', () => { + const guard1: NavigationGuard = { + name: 'Guard1', + evaluate: () => ({type: 'ALLOW'}), + }; + const guard2: NavigationGuard = { + name: 'Guard2', + evaluate: () => ({type: 'ALLOW'}), + }; + + registerGuard(guard1); + registerGuard(guard2); + + const guards = getRegisteredGuards(); + expect(guards).toHaveLength(2); + expect(guards.at(0)?.name).toBe('Guard1'); + expect(guards.at(1)?.name).toBe('Guard2'); + }); + }); + + describe('clearGuards', () => { + it('should remove all registered guards', () => { + const mockGuard: NavigationGuard = { + name: 'TestGuard', + evaluate: () => ({type: 'ALLOW'}), + }; + + registerGuard(mockGuard); + expect(getRegisteredGuards()).toHaveLength(1); + + clearGuards(); + expect(getRegisteredGuards()).toHaveLength(0); + }); + }); + + describe('createGuardContext', () => { + it('should create a guard context with expected properties', () => { + const context = createGuardContext(); + + expect(context).toHaveProperty('isAuthenticated'); + expect(context).toHaveProperty('isLoading'); + expect(context).toHaveProperty('currentUrl'); + expect(typeof context.isAuthenticated).toBe('boolean'); + expect(typeof context.isLoading).toBe('boolean'); + expect(typeof context.currentUrl).toBe('string'); + }); + }); + + describe('evaluateGuards', () => { + const mockState = { + key: 'test-key', + index: 0, + routeNames: [], + routes: [], + type: 'test', + stale: false, + } as NavigationState; + const mockAction = {type: 'NAVIGATE', payload: {name: 'TestScreen'}} as const; + const mockContext: GuardContext = { + isAuthenticated: true, + isLoading: false, + currentUrl: '', + }; + + it('should return ALLOW when no guards are registered', () => { + const result = evaluateGuards(mockState, mockAction, mockContext); + expect(result).toEqual({type: 'ALLOW'}); + }); + + it('should evaluate guards', () => { + const evaluateFn = jest.fn(() => ({type: 'ALLOW' as const})); + const mockGuard: NavigationGuard = { + name: 'TestGuard', + evaluate: evaluateFn, + }; + + registerGuard(mockGuard); + evaluateGuards(mockState, mockAction, mockContext); + + expect(evaluateFn).toHaveBeenCalledWith(mockState, mockAction, mockContext); + }); + + it('should short-circuit on BLOCK result', () => { + const guard1Evaluate = jest.fn(() => ({type: 'BLOCK' as const, reason: 'Blocked'})); + const guard2Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); + + const guard1: NavigationGuard = { + name: 'BlockingGuard', + evaluate: guard1Evaluate, + }; + const guard2: NavigationGuard = { + name: 'AllowGuard', + evaluate: guard2Evaluate, + }; + + registerGuard(guard1); + registerGuard(guard2); + + const result = evaluateGuards(mockState, mockAction, mockContext); + + expect(result).toEqual({type: 'BLOCK', reason: 'Blocked'}); + expect(guard1Evaluate).toHaveBeenCalled(); + expect(guard2Evaluate).not.toHaveBeenCalled(); + }); + + it('should short-circuit on REDIRECT result', () => { + const guard1Evaluate = jest.fn(() => ({type: 'REDIRECT' as const, route: ROUTES.HOME})); + const guard2Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); + + const guard1: NavigationGuard = { + name: 'RedirectGuard', + evaluate: guard1Evaluate, + }; + const guard2: NavigationGuard = { + name: 'AllowGuard', + evaluate: guard2Evaluate, + }; + + registerGuard(guard1); + registerGuard(guard2); + + const result = evaluateGuards(mockState, mockAction, mockContext); + + expect(result).toEqual({type: 'REDIRECT', route: ROUTES.HOME}); + expect(guard1Evaluate).toHaveBeenCalled(); + expect(guard2Evaluate).not.toHaveBeenCalled(); + }); + + it('should continue evaluation when guard returns ALLOW', () => { + const guard1Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); + const guard2Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); + + const guard1: NavigationGuard = { + name: 'AllowGuard1', + evaluate: guard1Evaluate, + }; + const guard2: NavigationGuard = { + name: 'AllowGuard2', + evaluate: guard2Evaluate, + }; + + registerGuard(guard1); + registerGuard(guard2); + + const result = evaluateGuards(mockState, mockAction, mockContext); + + expect(result).toEqual({type: 'ALLOW'}); + expect(guard1Evaluate).toHaveBeenCalled(); + expect(guard2Evaluate).toHaveBeenCalled(); + }); + + it('should evaluate guards in registration order', () => { + const executionOrder: string[] = []; + + const guard1: NavigationGuard = { + name: 'Guard1', + evaluate: () => { + executionOrder.push('Guard1'); + return {type: 'ALLOW'}; + }, + }; + const guard2: NavigationGuard = { + name: 'Guard2', + evaluate: () => { + executionOrder.push('Guard2'); + return {type: 'ALLOW'}; + }, + }; + const guard3: NavigationGuard = { + name: 'Guard3', + evaluate: () => { + executionOrder.push('Guard3'); + return {type: 'ALLOW'}; + }, + }; + + registerGuard(guard1); + registerGuard(guard2); + registerGuard(guard3); + + evaluateGuards(mockState, mockAction, mockContext); + + expect(executionOrder).toEqual(['Guard1', 'Guard2', 'Guard3']); + }); + }); +}); diff --git a/tests/unit/Navigation/guards/OnboardingGuard.test.ts b/tests/unit/Navigation/guards/OnboardingGuard.test.ts new file mode 100644 index 000000000000..cd632508c2c6 --- /dev/null +++ b/tests/unit/Navigation/guards/OnboardingGuard.test.ts @@ -0,0 +1,275 @@ +import type {NavigationAction, NavigationState} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; +import OnboardingGuard from '@libs/Navigation/guards/OnboardingGuard'; +import type {GuardContext} from '@libs/Navigation/guards/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; + +describe('OnboardingGuard', () => { + const mockState: NavigationState = { + key: 'root', + index: 0, + routeNames: [SCREENS.HOME], + routes: [{key: 'home', name: SCREENS.HOME}], + stale: false, + type: 'root', + }; + + const mockAction: NavigationAction = { + type: 'NAVIGATE', + payload: {name: SCREENS.HOME}, + }; + + const authenticatedContext: GuardContext = { + isAuthenticated: true, + isLoading: false, + currentUrl: '', + }; + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + }); + + describe('early return when onboarding completed', () => { + it('should return ALLOW when user has completed onboarding', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); + expect(result.type).toBe('ALLOW'); + }); + + it('should return ALLOW when onboarding data is undefined (old/migrated accounts)', async () => { + // Empty/null onboarding means old account - considered completed + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, null); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); + expect(result.type).toBe('ALLOW'); + }); + }); + + describe('early exit conditions', () => { + it('should allow during app transition', () => { + const transitionContext: GuardContext = { + isAuthenticated: true, + isLoading: false, + currentUrl: 'https://new.expensify.com/transition', + }; + + const result = OnboardingGuard.evaluate(mockState, mockAction, transitionContext); + + expect(result.type).toBe('ALLOW'); + }); + + it('should BLOCK RESET action when user is on onboarding and tries to reset to non-onboarding screen', async () => { + // User is currently on an onboarding screen + const onboardingState: NavigationState = { + key: 'root', + index: 0, + routeNames: [SCREENS.ONBOARDING.PURPOSE], + routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}], + stale: false, + type: 'root', + }; + + // RESET action trying to navigate to a non-onboarding screen (HOME) + const resetAction: NavigationAction = { + type: CONST.NAVIGATION_ACTIONS.RESET, + payload: { + key: 'root', + index: 0, + routeNames: [SCREENS.HOME], + routes: [{key: 'home', name: SCREENS.HOME}], + stale: false, + type: 'root', + }, + }; + + const result = OnboardingGuard.evaluate(onboardingState, resetAction, authenticatedContext) as {type: 'BLOCK'; reason?: string}; + + expect(result.type).toBe('BLOCK'); + expect(result.reason).toBe('Cannot reset to non-onboarding screen while on onboarding'); + }); + }); + + describe('skip onboarding conditions', () => { + it('should allow when onboarding is completed', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); + + expect(result.type).toBe('ALLOW'); + }); + + it('should allow migrated users', async () => { + await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, { + classicRedirect: { + dismissed: false, + }, + nudgeMigration: { + timestamp: new Date(), + cohort: 'test', + }, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); + + expect(result.type).toBe('ALLOW'); + }); + + it('should allow users with single entry from HybridApp', async () => { + await Onyx.merge(ONYXKEYS.HYBRID_APP, { + isSingleNewDotEntry: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); + + expect(result.type).toBe('ALLOW'); + }); + + it('should allow users with non-personal policies', async () => { + await Onyx.merge(ONYXKEYS.HAS_NON_PERSONAL_POLICY, true); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); + + expect(result.type).toBe('ALLOW'); + }); + + it('should allow invited users', async () => { + await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, { + choice: CONST.INTRO_CHOICES.SUBMIT, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); + + expect(result.type).toBe('ALLOW'); + }); + }); + + describe('redirect to onboarding', () => { + it('should redirect when authenticated user needs onboarding', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; + + expect(result.type).toBe('REDIRECT'); + expect(result.route).toContain('onboarding'); + }); + + it('should redirect to correct step for users with accessible policies', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: false, + hasAccessibleDomainPolicies: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; + + expect(result.type).toBe('REDIRECT'); + expect(result.route).toContain('onboarding'); + }); + + it('should redirect when user tries to access wrong onboarding step', async () => { + // User is on onboarding/purpose but should be on onboarding/work-email + const onboardingState: NavigationState = { + key: 'root', + index: 0, + routeNames: [SCREENS.ONBOARDING.PURPOSE], + routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}], + stale: false, + type: 'root', + }; + + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(onboardingState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; + + expect(result.type).toBe('REDIRECT'); + expect(result.route).toContain('onboarding'); + }); + + it('should redirect when user in onboarding tries to access non-onboarding path', async () => { + // User is on onboarding screen but tries to navigate to home + const onboardingState: NavigationState = { + key: 'root', + index: 0, + routeNames: [SCREENS.ONBOARDING.PURPOSE], + routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}], + stale: false, + type: 'root', + }; + + const homeAction: NavigationAction = { + type: 'NAVIGATE', + payload: {name: SCREENS.HOME}, + }; + + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(onboardingState, homeAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; + + expect(result.type).toBe('REDIRECT'); + expect(result.route).toContain('onboarding'); + }); + + it('should always redirect to correct onboarding step when user needs onboarding', async () => { + // Even if user is on an onboarding screen, guard redirects to the correct step + const onboardingState: NavigationState = { + key: 'root', + index: 0, + routeNames: [SCREENS.ONBOARDING.WORK_EMAIL], + routes: [{key: 'work-email', name: SCREENS.ONBOARDING.WORK_EMAIL}], + stale: false, + type: 'root', + }; + + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + }); + await Onyx.merge(ONYXKEYS.ACCOUNT, { + isFromPublicDomain: true, + }); + await waitForBatchedUpdates(); + + const result = OnboardingGuard.evaluate(onboardingState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; + + // Guard should redirect to ensure user is on correct step + expect(result.type).toBe('REDIRECT'); + expect(result.route).toContain('onboarding'); + }); + }); +}); diff --git a/tests/unit/Navigation/guards/TestDriveModalGuard.test.ts b/tests/unit/Navigation/guards/TestDriveModalGuard.test.ts new file mode 100644 index 000000000000..26dc5f713529 --- /dev/null +++ b/tests/unit/Navigation/guards/TestDriveModalGuard.test.ts @@ -0,0 +1,216 @@ +import type {NavigationAction, NavigationState} from '@react-navigation/native'; +import Onyx from 'react-native-onyx'; +import TestDriveModalGuard, {resetSessionFlag} from '@libs/Navigation/guards/TestDriveModalGuard'; +import type {GuardContext} from '@libs/Navigation/guards/types'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; + +describe('TestDriveModalGuard', () => { + const mockState: NavigationState = { + key: 'root', + index: 0, + routeNames: [SCREENS.HOME], + routes: [{key: 'home', name: SCREENS.HOME}], + stale: false, + type: 'root', + }; + + const mockAction: NavigationAction = { + type: 'NAVIGATE', + payload: {name: SCREENS.HOME}, + }; + + const defaultContext: GuardContext = { + isAuthenticated: true, + isLoading: false, + currentUrl: '', + }; + + beforeEach(async () => { + await Onyx.clear(); + resetSessionFlag(); + await waitForBatchedUpdates(); + }); + + it('should allow when app is loading', () => { + const result = TestDriveModalGuard.evaluate(mockState, mockAction, {...defaultContext, isLoading: true}); + expect(result.type).toBe('ALLOW'); + }); + + it('should redirect when onboarding complete and modal not dismissed', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: false, + }); + await waitForBatchedUpdates(); + + const result = TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(result.type).toBe('REDIRECT'); + if (result.type === 'REDIRECT') { + expect(result.route).toBe(ROUTES.TEST_DRIVE_MODAL_ROOT.route); + } + }); + + it('should allow when modal dismissed', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: true, + }); + await waitForBatchedUpdates(); + + const result = TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(result.type).toBe('ALLOW'); + }); + + it('should allow when onboarding not complete', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: false, + testDriveModalDismissed: false, + }); + await waitForBatchedUpdates(); + + const result = TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(result.type).toBe('ALLOW'); + }); + + it('should not redirect multiple times (session flag)', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: false, + }); + await waitForBatchedUpdates(); + + const firstResult = TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(firstResult.type).toBe('REDIRECT'); + + const secondResult = TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(secondResult.type).toBe('ALLOW'); + }); + + it('should skip modal when user has accessible workspace', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: false, + }); + await Onyx.set(ONYXKEYS.ONBOARDING_POLICY_ID, 'policy123'); + await Onyx.set(ONYXKEYS.HAS_NON_PERSONAL_POLICY, true); + await waitForBatchedUpdates(); + + const result = TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(result.type).toBe('ALLOW'); + }); + + it('should redirect to home when accessing dismissed modal via URL', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: true, + }); + await waitForBatchedUpdates(); + + const testDriveModalState: NavigationState = { + key: 'root', + index: 0, + routeNames: [SCREENS.TEST_DRIVE_MODAL.ROOT], + routes: [{key: 'testDrive', name: SCREENS.TEST_DRIVE_MODAL.ROOT}], + stale: false, + type: 'root', + }; + + const result = TestDriveModalGuard.evaluate(testDriveModalState, mockAction, defaultContext); + expect(result.type).toBe('REDIRECT'); + if (result.type === 'REDIRECT') { + expect(result.route).toBe(ROUTES.HOME); + } + }); + + describe('shouldBlockWhileModalActive', () => { + const stateWithModalOnTop: NavigationState = { + key: 'root', + index: 1, + routeNames: [SCREENS.HOME, NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR], + routes: [ + {key: 'home', name: SCREENS.HOME}, + {key: 'testDriveModal', name: NAVIGATORS.TEST_DRIVE_MODAL_NAVIGATOR}, + ], + stale: false, + type: 'stack', + }; + + const tabSwitchAction: NavigationAction = { + type: CONST.NAVIGATION.ACTION_TYPE.PUSH, + payload: {name: NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR}, + }; + + const dismissModalAction: NavigationAction = { + type: CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL, + }; + + const goBackAction: NavigationAction = { + type: CONST.NAVIGATION.ACTION_TYPE.GO_BACK, + }; + + it('should block tab switches when the test drive modal is on top', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: false, + }); + await waitForBatchedUpdates(); + + // Trigger the redirect first so hasRedirectedToTestDriveModal is true + TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + + const result = TestDriveModalGuard.evaluate(stateWithModalOnTop, tabSwitchAction, defaultContext); + expect(result.type).toBe('BLOCK'); + }); + + it('should allow DISMISS_MODAL when the test drive modal is on top', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: false, + }); + await waitForBatchedUpdates(); + + // Trigger the redirect first + TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + + const result = TestDriveModalGuard.evaluate(stateWithModalOnTop, dismissModalAction, defaultContext); + expect(result.type).not.toBe('BLOCK'); + }); + + it('should allow GO_BACK when the test drive modal is on top', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: false, + }); + await waitForBatchedUpdates(); + + // Trigger the redirect first + TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + + const result = TestDriveModalGuard.evaluate(stateWithModalOnTop, goBackAction, defaultContext); + expect(result.type).not.toBe('BLOCK'); + }); + + it('should not block when the modal has been dismissed', async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { + hasCompletedGuidedSetupFlow: true, + testDriveModalDismissed: false, + }); + await waitForBatchedUpdates(); + + // Trigger the redirect first + TestDriveModalGuard.evaluate(mockState, mockAction, defaultContext); + + // Now mark modal as dismissed + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {testDriveModalDismissed: true}); + await waitForBatchedUpdates(); + + const result = TestDriveModalGuard.evaluate(stateWithModalOnTop, tabSwitchAction, defaultContext); + expect(result.type).not.toBe('BLOCK'); + }); + }); +}); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 1fc7414a5ef4..600cedbd5896 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,4 +1,3 @@ -import {waitFor} from '@testing-library/react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; @@ -144,12 +143,4 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(true, true, '', new Set(), ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); - - it('should navigate to Test Drive Modal if user wants to manage a small team', async () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - jest.spyOn(Navigation, 'isNavigationReady').mockReturnValue(Promise.resolve()); - - navigateAfterOnboarding(true, true, '', new Set()); - await waitFor(() => expect(navigate).toHaveBeenCalledWith(ROUTES.TEST_DRIVE_MODAL_ROOT.route)); - }); });