Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ 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 @@ -336,7 +339,10 @@ function Expensify() {
setInitialUrl(url as Route);

if (url) {
openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID);
if (conciergeReportID === undefined) {
Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url});
}
openReportFromDeepLink(url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isAuthenticated, conciergeReportID);
} else {
Report.doneCheckingPublicRoom();
}
Expand All @@ -350,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, allReports, isCurrentlyAuthenticated, conciergeReportID);
openReportFromDeepLink(state.url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isCurrentlyAuthenticated, conciergeReportID);
});

return () => {
Expand Down
72 changes: 51 additions & 21 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} from 'react';
import {useEffect, useMemo, useRef} from 'react';
import {InteractionManager} from 'react-native';
import {startOnboardingFlow} from '@libs/actions/Welcome/OnboardingFlow';
import Log from '@libs/Log';
Expand Down Expand Up @@ -29,16 +29,20 @@ function useOnboardingFlowRouter() {
const [onboardingValues, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {
canBeMissing: true,
});
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {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 [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 @@ -52,7 +56,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 showing onboarding if we are logging in as a new user with short lived token
// Prevent starting the onboarding flow if we are logging in as a new user with short lived token
if (currentUrl?.includes(ROUTES.TRANSITION_BETWEEN_APPS) && isLoggingInAsNewSessionUser) {
return;
}
Expand All @@ -74,22 +78,6 @@ 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 @@ -101,6 +89,12 @@ 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 @@ -111,6 +105,37 @@ 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,
});
Comment on lines +126 to +138

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip onboarding for invited/group users before starting flow

The new non‑hybrid onboarding trigger starts the flow whenever isOnboardingCompleted is false, but it no longer checks the same exclusion conditions used elsewhere (e.g., invited users or accounts with non‑personal policies). Because those exclusions are not evaluated here, a user who is invited to NewDot or already in a group workspace and still has hasCompletedGuidedSetupFlow=false will now be forced into onboarding on app load, even though the app explicitly avoids onboarding those cohorts in the initial state logic. Consider gating this block on the same conditions (e.g., hasNonPersonalPolicy / wasInvitedToNewDot) to avoid regressions for invited/group accounts.

Useful? React with 👍 / 👎.

}
});

Expand All @@ -127,11 +152,16 @@ 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,11 +1,9 @@
import {CommonActions, StackRouter} from '@react-navigation/native';
import type {RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native';
import {findFocusedRoute, StackRouter} from '@react-navigation/native';
import type {ParamListBase} from '@react-navigation/routers';
import {createGuardContext, evaluateGuards} from '@libs/Navigation/guards';
import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath';
import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName';
import {isFullScreenName, isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName';
import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator';
import {linkingConfig} from '@libs/Navigation/linkingConfig';
import * as Welcome from '@userActions/Welcome';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import {
Expand Down Expand Up @@ -58,45 +56,20 @@ function isPreloadAction(action: RootStackNavigatorAction): action is PreloadAct
return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD;
}

/**
* 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);
return state;
function shouldPreventReset(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType) {
if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) {
return false;
}
const currentFocusedRoute = findFocusedRoute(state);
const targetFocusedRoute = findFocusedRoute(action?.payload);

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);
// 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;
}

return null;
return false;
}

function isNavigatingToModalFromModal(state: StackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType): action is PushActionType {
Expand All @@ -117,14 +90,6 @@ 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 @@ -156,6 +121,12 @@ 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: 30 additions & 2 deletions src/libs/Navigation/NavigationRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type {NavigationState} from '@react-navigation/native';
import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native';
import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding';
import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} 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 @@ -16,6 +17,8 @@ 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 @@ -99,11 +102,20 @@ 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 @@ -124,6 +136,22 @@ 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