Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
e187919
create basic guard setup
TMisiukiewicz Jan 19, 2026
b7cb35c
add TwoFactorAuthGuard
TMisiukiewicz Jan 19, 2026
3f08445
add tests for guard setup and 2FA guard
TMisiukiewicz Jan 19, 2026
cb5646e
fix lint
TMisiukiewicz Jan 19, 2026
a7b5e51
fix lint
TMisiukiewicz Jan 20, 2026
c64153e
declare function globally
TMisiukiewicz Jan 20, 2026
4c1cc30
Merge remote-tracking branch 'origin/main' into navigation-guards
TMisiukiewicz Jan 20, 2026
65ea0e9
immediately apply the 2fa guard
TMisiukiewicz Jan 20, 2026
d88379c
remove unnecessary test
TMisiukiewicz Jan 20, 2026
dee7ecd
prevent navigating to onboarding from 2fa
TMisiukiewicz Jan 20, 2026
c919c5d
Merge remote-tracking branch 'origin/main' into navigation-guards
TMisiukiewicz Jan 21, 2026
ec932e2
navigation guards for initial state
TMisiukiewicz Jan 21, 2026
4df2560
add canBeMissing
TMisiukiewicz Jan 21, 2026
96473ca
temporarily use guardedLastVisitedState in navigationroot
TMisiukiewicz Jan 21, 2026
bacf417
reduce the diff
TMisiukiewicz Jan 21, 2026
7611092
prevent navigating away from 2fa flow
TMisiukiewicz Jan 21, 2026
5b5a2cd
Revert "prevent navigating away from 2fa flow"
TMisiukiewicz Jan 21, 2026
170a8d9
add early return
TMisiukiewicz Jan 21, 2026
34dfad2
Merge remote-tracking branch 'origin/main' into navigation-guards
TMisiukiewicz Jan 22, 2026
74ddc8c
Revert "Merge remote-tracking branch 'origin/main' into navigation-gu…
TMisiukiewicz Jan 22, 2026
cfbeda7
remove 2fa guard
TMisiukiewicz Jan 22, 2026
03cd0f8
Reapply "Merge remote-tracking branch 'origin/main' into navigation-g…
TMisiukiewicz Jan 22, 2026
84d423c
remove 2fa tests
TMisiukiewicz Jan 22, 2026
6dc1bdd
create onboarding guard
TMisiukiewicz Jan 22, 2026
6212c5b
wip: onboarding guard
TMisiukiewicz Jan 22, 2026
b8c3b08
reduce diff
TMisiukiewicz Jan 22, 2026
0f337eb
simplify onboarding guard logic
TMisiukiewicz Jan 22, 2026
15f4549
simplify onboarding guard
TMisiukiewicz Jan 22, 2026
c249972
cleanup
TMisiukiewicz Jan 22, 2026
21d388c
fix lint
TMisiukiewicz Jan 22, 2026
7a6ff9b
add logs
TMisiukiewicz Jan 22, 2026
68248cf
guard cleanup
TMisiukiewicz Jan 23, 2026
6d4e2b6
fix spellcheck
TMisiukiewicz Jan 23, 2026
c865998
add unit tests
TMisiukiewicz Jan 23, 2026
9d987ad
defer evaluation until app is loaded
TMisiukiewicz Jan 23, 2026
4325cab
fix code review comments
TMisiukiewicz Jan 23, 2026
8cfbb48
Merge remote-tracking branch 'origin/main' into navigation-guards
TMisiukiewicz Jan 27, 2026
2d5a040
code review updates
TMisiukiewicz Jan 27, 2026
9c4f575
fix tests
TMisiukiewicz Jan 27, 2026
ce30384
Merge branch 'main' into navigation-guards
TMisiukiewicz Jan 28, 2026
abbea9c
cleanup Welcome actions
TMisiukiewicz Jan 28, 2026
5a73f1b
Merge branch 'main' into navigation-guards
TMisiukiewicz Jan 28, 2026
a520526
do not call handleDeepLinkNavigation on every fn call
TMisiukiewicz Jan 28, 2026
4cfe39f
navigate to test drive modal
TMisiukiewicz Jan 28, 2026
53f8e1a
fix spellcheck
TMisiukiewicz Jan 29, 2026
83a4f92
Merge remote-tracking branch 'origin/main' into navigation-guards
TMisiukiewicz Jan 29, 2026
17c0702
remove shouldAllow method
TMisiukiewicz Jan 29, 2026
c68dcd6
evaluate navigation guards for deep links on cold start
TMisiukiewicz Jan 29, 2026
779a31b
Merge branch 'main' into navigation-guards
TMisiukiewicz Jan 29, 2026
ce3d538
update path check
TMisiukiewicz Jan 29, 2026
065ef17
Merge remote-tracking branch 'origin/main' into navigation-guards
TMisiukiewicz Jan 30, 2026
1ebe63d
add missing dependency in array
TMisiukiewicz Jan 30, 2026
31664f5
simplify OnboardingGuard
TMisiukiewicz Jan 30, 2026
b42e000
create single ALLOW condition
TMisiukiewicz Jan 30, 2026
f098daf
add BLOCK operations to OnboardingGuard
TMisiukiewicz Jan 30, 2026
3365c2c
simplify OnboardingGuard logic
TMisiukiewicz Jan 30, 2026
d3db6dc
add BLOCK test
TMisiukiewicz Jan 30, 2026
107a652
reduce diff
TMisiukiewicz Jan 30, 2026
282d6ff
fix tests
TMisiukiewicz Jan 30, 2026
1ff1768
Merge remote-tracking branch 'origin/main' into navigation-guards
TMisiukiewicz Feb 2, 2026
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
10 changes: 2 additions & 8 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,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});
Expand Down Expand Up @@ -345,10 +342,7 @@ function Expensify() {
setInitialUrl(url as Route);

if (url) {
if (conciergeReportID === undefined) {
Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url});
}
openReportFromDeepLink(url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isAuthenticated, conciergeReportID);
openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID);
} else {
Report.doneCheckingPublicRoom();
}
Expand All @@ -362,7 +356,7 @@ function Expensify() {
Log.info('[Deep link] conciergeReportID is undefined when processing URL change', false, {url: state.url});
}
const isCurrentlyAuthenticated = hasAuthToken();
openReportFromDeepLink(state.url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isCurrentlyAuthenticated, conciergeReportID);
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID);
});

return () => {
Expand Down
72 changes: 21 additions & 51 deletions src/hooks/useOnboardingFlow.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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';
Expand Down Expand Up @@ -29,20 +29,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});

Expand All @@ -56,7 +52,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;
}
Expand All @@ -78,6 +74,22 @@ function useOnboardingFlowRouter() {
return;
}

// Temporary solution to navigate to onboarding when trying to access the app
// Should be removed once Test Drive modal route has its own navigation guard
// Details: https://github.com/Expensify/App/pull/79898
if (hasCompletedGuidedSetupFlowSelector(onboardingValues) && onboardingValues?.testDriveModalDismissed === false) {
Navigation.setNavigationActionToMicrotaskQueue(() => {
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,
});
});
}
if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed('migratedUserWelcomeModal', dismissedProductTraining)) {
const navigationState = navigationRef.getRootState();
const lastRoute = navigationState.routes.at(-1);
Expand All @@ -89,12 +101,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) {
Expand All @@ -105,37 +111,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,
});
}
});

Expand All @@ -152,16 +127,11 @@ function useOnboardingFlowRouter() {
hasBeenAddedToNudgeMigration,
dismissedProductTrainingMetadata,
dismissedProductTraining?.migratedUserWelcomeModal,
onboardingValues,
dismissedProductTraining,
account?.isFromPublicDomain,
account?.hasAccessibleDomainPolicies,
currentUrl,
isLoggingInAsNewSessionUser,
currentOnboardingCompanySize,
currentOnboardingPurposeSelected,
onboardingInitialPath,
isOnboardingLoading,
onboardingValues,
]);

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -56,20 +58,45 @@ function isPreloadAction(action: RootStackNavigatorAction): action is PreloadAct
return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD;
}

function shouldPreventReset(state: StackNavigationState<ParamListBase>, 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<ParamListBase>,
action: RootStackNavigatorAction,
configOptions: RouterConfigOptions,
stackRouter: ReturnType<typeof StackRouter>,
): ReturnType<ReturnType<typeof StackRouter>['getStateForAction']> | null {
const guardContext = createGuardContext();
const guardResult = evaluateGuards(state, action, guardContext);

if (guardResult.type === 'BLOCK') {
syncBrowserHistory(state);
Comment thread
TMisiukiewicz marked this conversation as resolved.
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<ParamListBase>, action: CommonActions.Action | StackActionType): action is PushActionType {
Expand All @@ -90,6 +117,14 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) {
return {
...stackRouter,
getStateForAction(state: StackNavigationState<ParamListBase>, 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;
}
Expand Down Expand Up @@ -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);
}
Expand Down
32 changes: 2 additions & 30 deletions src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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());
Expand All @@ -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
Expand Down
Loading
Loading