Skip to content

Commit bcfa7b4

Browse files
authored
Merge pull request Expensify#79898 from callstack-internal/navigation-guards
2 parents e22c40e + 1ff1768 commit bcfa7b4

11 files changed

Lines changed: 876 additions & 250 deletions

File tree

src/Expensify.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,6 @@ function Expensify() {
119119
const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {canBeMissing: true});
120120
const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST, {canBeMissing: true});
121121
const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true});
122-
const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true});
123-
const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true});
124-
const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true});
125122
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false});
126123
const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true});
127124
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true});
@@ -339,10 +336,7 @@ function Expensify() {
339336
setInitialUrl(url as Route);
340337

341338
if (url) {
342-
if (conciergeReportID === undefined) {
343-
Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url});
344-
}
345-
openReportFromDeepLink(url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isAuthenticated, conciergeReportID);
339+
openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID);
346340
} else {
347341
Report.doneCheckingPublicRoom();
348342
}
@@ -356,7 +350,7 @@ function Expensify() {
356350
Log.info('[Deep link] conciergeReportID is undefined when processing URL change', false, {url: state.url});
357351
}
358352
const isCurrentlyAuthenticated = hasAuthToken();
359-
openReportFromDeepLink(state.url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isCurrentlyAuthenticated, conciergeReportID);
353+
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID);
360354
});
361355

362356
return () => {

src/hooks/useOnboardingFlow.ts

Lines changed: 21 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {isSingleNewDotEntrySelector} from '@selectors/HybridApp';
22
import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding';
33
import {emailSelector} from '@selectors/Session';
4-
import {useEffect, useMemo, useRef} from 'react';
4+
import {useEffect, useMemo} from 'react';
55
import {InteractionManager} from 'react-native';
66
import {startOnboardingFlow} from '@libs/actions/Welcome/OnboardingFlow';
77
import Log from '@libs/Log';
@@ -29,20 +29,16 @@ function useOnboardingFlowRouter() {
2929
const [onboardingValues, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
3030
canBeMissing: true,
3131
});
32-
const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true});
33-
const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true});
34-
const [onboardingInitialPath, onboardingInitialPathMetadata] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true});
35-
const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});
36-
const isOnboardingLoading = isLoadingOnyxValue(onboardingInitialPathMetadata, accountMetadata);
32+
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});
3733

3834
const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true, selector: emailSelector});
3935
const isLoggingInAsNewSessionUser = isLoggingInAsNewUser(currentUrl, sessionEmail);
40-
const startedOnboardingFlowRef = useRef(false);
4136
const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {
4237
selector: tryNewDotOnyxSelector,
4338
canBeMissing: true,
4439
});
4540
const {isHybridAppOnboardingCompleted, hasBeenAddedToNudgeMigration} = tryNewDot ?? {};
41+
const isOnboardingLoading = isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotMetadata);
4642

4743
const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true});
4844

@@ -56,7 +52,7 @@ function useOnboardingFlowRouter() {
5652
// This should delay opening the onboarding modal so it does not interfere with the ongoing ReportScreen params changes
5753
// eslint-disable-next-line @typescript-eslint/no-deprecated
5854
const handle = InteractionManager.runAfterInteractions(() => {
59-
// Prevent starting the onboarding flow if we are logging in as a new user with short lived token
55+
// Prevent showing onboarding if we are logging in as a new user with short lived token
6056
if (currentUrl?.includes(ROUTES.TRANSITION_BETWEEN_APPS) && isLoggingInAsNewSessionUser) {
6157
return;
6258
}
@@ -78,6 +74,22 @@ function useOnboardingFlowRouter() {
7874
return;
7975
}
8076

77+
// Temporary solution to navigate to onboarding when trying to access the app
78+
// Should be removed once Test Drive modal route has its own navigation guard
79+
// Details: https://github.com/Expensify/App/pull/79898
80+
if (hasCompletedGuidedSetupFlowSelector(onboardingValues) && onboardingValues?.testDriveModalDismissed === false) {
81+
Navigation.setNavigationActionToMicrotaskQueue(() => {
82+
Log.info('[Onboarding] User has not completed the guided setup flow, starting onboarding flow from test drive modal');
83+
startOnboardingFlow({
84+
onboardingInitialPath: ROUTES.TEST_DRIVE_MODAL_ROOT.route,
85+
isUserFromPublicDomain: false,
86+
hasAccessiblePolicies: false,
87+
currentOnboardingCompanySize: undefined,
88+
currentOnboardingPurposeSelected: undefined,
89+
onboardingValues,
90+
});
91+
});
92+
}
8193
if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed('migratedUserWelcomeModal', dismissedProductTraining)) {
8294
const navigationState = navigationRef.getRootState();
8395
const lastRoute = navigationState.routes.at(-1);
@@ -89,12 +101,6 @@ function useOnboardingFlowRouter() {
89101
return;
90102
}
91103

92-
if (hasBeenAddedToNudgeMigration) {
93-
return;
94-
}
95-
96-
const isOnboardingCompleted = hasCompletedGuidedSetupFlowSelector(onboardingValues) && onboardingValues?.testDriveModalDismissed !== false;
97-
98104
if (CONFIG.IS_HYBRID_APP) {
99105
// For single entries, such as using the Travel feature from OldDot, we don't want to show onboarding
100106
if (isSingleNewDotEntry) {
@@ -105,37 +111,6 @@ function useOnboardingFlowRouter() {
105111
if (isHybridAppOnboardingCompleted === false) {
106112
Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT);
107113
}
108-
109-
// But if the hybrid app onboarding is completed, but NewDot onboarding is not completed, we start NewDot onboarding flow
110-
// This is a special case when user created an account from NewDot without finishing the onboarding flow and then logged in from OldDot
111-
if (isHybridAppOnboardingCompleted === true && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) {
112-
startedOnboardingFlowRef.current = true;
113-
Log.info('[Onboarding] Hybrid app onboarding is completed, but NewDot onboarding is not completed, starting NewDot onboarding flow');
114-
startOnboardingFlow({
115-
onboardingValuesParam: onboardingValues,
116-
isUserFromPublicDomain: !!account?.isFromPublicDomain,
117-
hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies,
118-
currentOnboardingCompanySize,
119-
currentOnboardingPurposeSelected,
120-
onboardingInitialPath,
121-
onboardingValues,
122-
});
123-
}
124-
}
125-
126-
// If the user is not transitioning from OldDot to NewDot, we should start NewDot onboarding flow if it's not completed yet
127-
if (!CONFIG.IS_HYBRID_APP && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) {
128-
startedOnboardingFlowRef.current = true;
129-
Log.info('[Onboarding] Not a hybrid app, NewDot onboarding is not completed, starting NewDot onboarding flow');
130-
startOnboardingFlow({
131-
onboardingValuesParam: onboardingValues,
132-
isUserFromPublicDomain: !!account?.isFromPublicDomain,
133-
hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies,
134-
currentOnboardingCompanySize,
135-
currentOnboardingPurposeSelected,
136-
onboardingInitialPath,
137-
onboardingValues,
138-
});
139114
}
140115
});
141116

@@ -152,16 +127,11 @@ function useOnboardingFlowRouter() {
152127
hasBeenAddedToNudgeMigration,
153128
dismissedProductTrainingMetadata,
154129
dismissedProductTraining?.migratedUserWelcomeModal,
155-
onboardingValues,
156130
dismissedProductTraining,
157-
account?.isFromPublicDomain,
158-
account?.hasAccessibleDomainPolicies,
159131
currentUrl,
160132
isLoggingInAsNewSessionUser,
161-
currentOnboardingCompanySize,
162-
currentOnboardingPurposeSelected,
163-
onboardingInitialPath,
164133
isOnboardingLoading,
134+
onboardingValues,
165135
]);
166136

167137
return {

src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
2-
import {findFocusedRoute, StackRouter} from '@react-navigation/native';
1+
import {CommonActions, StackRouter} from '@react-navigation/native';
2+
import type {RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
33
import type {ParamListBase} from '@react-navigation/routers';
4-
import {isFullScreenName, isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName';
4+
import {createGuardContext, evaluateGuards} from '@libs/Navigation/guards';
5+
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
6+
import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName';
57
import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator';
6-
import * as Welcome from '@userActions/Welcome';
8+
import {linkingConfig} from '@libs/Navigation/linkingConfig';
79
import CONST from '@src/CONST';
810
import NAVIGATORS from '@src/NAVIGATORS';
911
import {
@@ -56,20 +58,45 @@ function isPreloadAction(action: RootStackNavigatorAction): action is PreloadAct
5658
return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD;
5759
}
5860

59-
function shouldPreventReset(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType) {
60-
if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) {
61-
return false;
61+
/**
62+
* Evaluates navigation guards and handles BLOCK/REDIRECT results
63+
*
64+
* @param state - Current navigation state
65+
* @param action - Navigation action being attempted
66+
* @param configOptions - Router configuration options
67+
* @param stackRouter - Stack router instance
68+
* @returns Modified state if guard blocks/redirects, null if navigation should proceed
69+
*/
70+
function handleNavigationGuards(
71+
state: StackNavigationState<ParamListBase>,
72+
action: RootStackNavigatorAction,
73+
configOptions: RouterConfigOptions,
74+
stackRouter: ReturnType<typeof StackRouter>,
75+
): ReturnType<ReturnType<typeof StackRouter>['getStateForAction']> | null {
76+
const guardContext = createGuardContext();
77+
const guardResult = evaluateGuards(state, action, guardContext);
78+
79+
if (guardResult.type === 'BLOCK') {
80+
syncBrowserHistory(state);
81+
return state;
6282
}
63-
const currentFocusedRoute = findFocusedRoute(state);
64-
const targetFocusedRoute = findFocusedRoute(action?.payload);
6583

66-
// We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen
67-
if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) {
68-
Welcome.setOnboardingErrorMessage('onboarding.purpose.errorBackButton');
69-
return true;
84+
if (guardResult.type === 'REDIRECT') {
85+
const redirectState = getAdaptedStateFromPath(guardResult.route, linkingConfig.config);
86+
87+
if (!redirectState || !redirectState.routes) {
88+
return null;
89+
}
90+
91+
const resetAction = CommonActions.reset({
92+
index: redirectState.index ?? redirectState.routes.length - 1,
93+
routes: redirectState.routes,
94+
});
95+
96+
return stackRouter.getStateForAction(state, resetAction, configOptions);
7097
}
7198

72-
return false;
99+
return null;
73100
}
74101

75102
function isNavigatingToModalFromModal(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType): action is PushActionType {
@@ -90,6 +117,14 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) {
90117
return {
91118
...stackRouter,
92119
getStateForAction(state: StackNavigationState<ParamListBase>, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) {
120+
// Evaluate navigation guards FIRST
121+
const guardState = handleNavigationGuards(state, action, configOptions, stackRouter);
122+
if (guardState) {
123+
return guardState;
124+
}
125+
126+
// Guards allowed navigation - continue with routing logic
127+
93128
if (isPreloadAction(action) && action.payload.name === state.routes.at(-1)?.name) {
94129
return state;
95130
}
@@ -121,12 +156,6 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) {
121156
return handlePushFullscreenAction(state, action, configOptions, stackRouter);
122157
}
123158

124-
// 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.
125-
if (shouldPreventReset(state, action)) {
126-
syncBrowserHistory(state);
127-
return state;
128-
}
129-
130159
if (isNavigatingToModalFromModal(state, action)) {
131160
return handleNavigatingToModalFromModal(state, action, configOptions, stackRouter);
132161
}

src/libs/Navigation/NavigationRoot.tsx

Lines changed: 2 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type {NavigationState} from '@react-navigation/native';
22
import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native';
3-
import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding';
3+
import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
44
import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react';
5-
import {useOnboardingValues} from '@components/OnyxListItemProvider';
65
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
76
import {useCurrentReportIDActions} from '@hooks/useCurrentReportID';
87
import useOnyx from '@hooks/useOnyx';
@@ -17,8 +16,6 @@ import shouldOpenLastVisitedPath from '@libs/shouldOpenLastVisitedPath';
1716
import {getPathFromURL} from '@libs/Url';
1817
import {updateLastVisitedPath} from '@userActions/App';
1918
import {updateOnboardingLastVisitedPath} from '@userActions/Welcome';
20-
import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow';
21-
import CONFIG from '@src/CONFIG';
2219
import CONST from '@src/CONST';
2320
import {endSpan, getSpan, startSpan} from '@src/libs/telemetry/activeSpans';
2421
import {navigationIntegration} from '@src/libs/telemetry/integrations';
@@ -102,20 +99,11 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
10299
selector: hasCompletedGuidedSetupFlowSelector,
103100
canBeMissing: true,
104101
});
105-
const [wasInvitedToNewDot = false] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {
106-
selector: wasInvitedToNewDotSelector,
107-
canBeMissing: true,
108-
});
109-
const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY, {canBeMissing: true});
110-
const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true});
111-
const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true});
112-
const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true});
113-
const onboardingValues = useOnboardingValues();
102+
114103
const previousAuthenticated = usePrevious(authenticated);
115104

116105
const initialState = useMemo(() => {
117106
const path = initialUrl ? getPathFromURL(initialUrl) : null;
118-
119107
if (path?.includes(ROUTES.MIGRATED_USER_WELCOME_MODAL.route) && shouldOpenLastVisitedPath(lastVisitedPath) && isOnboardingCompleted && authenticated) {
120108
Navigation.isNavigationReady().then(() => {
121109
Navigation.navigate(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute());
@@ -136,22 +124,6 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N
136124
return undefined;
137125
}
138126

139-
// If the user haven't completed the flow, we want to always redirect them to the onboarding flow.
140-
// 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.
141-
if (!CONFIG.IS_HYBRID_APP && !hasNonPersonalPolicy && !isOnboardingCompleted && !wasInvitedToNewDot && authenticated) {
142-
return getAdaptedStateFromPath(
143-
getOnboardingInitialPath({
144-
isUserFromPublicDomain: !!account.isFromPublicDomain,
145-
hasAccessiblePolicies: !!account.hasAccessibleDomainPolicies,
146-
currentOnboardingPurposeSelected,
147-
currentOnboardingCompanySize,
148-
onboardingInitialPath,
149-
onboardingValues,
150-
}),
151-
linkingConfig.config,
152-
);
153-
}
154-
155127
if (shouldOpenLastVisitedPath(lastVisitedPath) && authenticated) {
156128
// Only skip restoration if there's a specific deep link that's not the root
157129
// This allows restoration when app is killed and reopened without a deep link

0 commit comments

Comments
 (0)