From 72e106e9f3627addbd1e42fcc6f052b83a7b70bf Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 18 Aug 2026 16:53:32 +0200 Subject: [PATCH 01/13] Add onboarding intent deeplink that creates a Submit workspace Marketing emails need a plain URL that drops the recipient into a ready-to-use Submit workspace. Today that outcome is only reachable by picking the intent in the onboarding UI, so it can't be expressed as a link. Support an `intent` param on the onboarding route and act on it once the authenticated screens mount, reusing the existing auto-create hook so the existing-workspace and restricted-policy-creation guards keep repeat clicks idempotent. --- src/CONST/index.ts | 12 ++++ src/ROUTES.ts | 8 ++- .../Navigation/AppNavigator/AuthScreens.tsx | 2 + .../ApplySubmitOnboardingIntent.tsx | 55 +++++++++++++++++++ .../SubmitIntentDeeplinkHandler/index.tsx | 32 +++++++++++ src/libs/getOnboardingIntentFromUrl.ts | 47 ++++++++++++++++ tests/unit/getOnboardingIntentFromUrlTest.ts | 42 ++++++++++++++ 7 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx create mode 100644 src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx create mode 100644 src/libs/getOnboardingIntentFromUrl.ts create mode 100644 tests/unit/getOnboardingIntentFromUrlTest.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5e31979330bd..c481e31dd9cf 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -143,6 +143,13 @@ const createExpenseOnboardingChoices = { SUBMIT: backendOnboardingChoices.SUBMIT, } as const; +// Values accepted by the `intent` param on the onboarding deeplink (e.g. `onboarding?intent=submit`). These are +// short, stable, marketing-friendly aliases rather than the internal onboarding choice strings, because they are +// embedded in emails and other links we can't redeploy. +const onboardingIntents = { + SUBMIT: 'submit', +} as const; + const signupQualifiers = { INDIVIDUAL: 'individual', VSB: 'vsb', @@ -6653,6 +6660,7 @@ const CONST = { EXPENSIFY_ICON_NAME: 'Expensify', ONBOARDING_CHOICES: {...onboardingChoices}, + ONBOARDING_INTENTS: {...onboardingIntents}, SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, CREATE_EXPENSE_ONBOARDING_CHOICES: {...createExpenseOnboardingChoices}, ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers}, @@ -9578,6 +9586,9 @@ type IOUActionParams = ValueOf; type SubscriptionType = ValueOf; type CancellationType = ValueOf; +/** Valid values for the `intent` param on the onboarding deeplink */ +type OnboardingIntent = ValueOf; + /** Valid `page` values for the Enable Payments flow */ type EnablePaymentsPageType = ValueOf; @@ -9598,6 +9609,7 @@ export type { CancellationType, OnboardingInvite, OnboardingAccounting, + OnboardingIntent, IOUActionParams, EnablePaymentsPageType, EnablePaymentsSubPageType, diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 116d707e75c5..cb2355995d0f 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -9,7 +9,7 @@ import type {UpperCaseCharacters} from 'type-fest/source/internal'; import type {SearchFilterKey, SearchQueryString, UserFriendlyKey} from './components/Search/types'; import type CONST from './CONST'; -import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType} from './CONST'; +import type {EnablePaymentsPageType, EnablePaymentsSubPageType, IOUAction, IOURequestType, IOUType, OdometerImageType, OnboardingIntent} from './CONST'; import type {ReplacementReason} from './libs/actions/Card'; import type {RootNavigatorParamList} from './libs/Navigation/types'; import type {Screen} from './SCREENS'; @@ -3939,7 +3939,11 @@ const ROUTES = { ONBOARDING_ROOT: { route: 'onboarding', - getRoute: () => 'onboarding' as const, + /** + * @param intent - Pre-selects an onboarding outcome so a one-click link can land the user on the + * matching workspace instead of making them pick the intent in the UI. + */ + getRoute: (intent?: OnboardingIntent) => (intent ? (`onboarding?intent=${intent}` as const) : ('onboarding' as const)), }, ONBOARDING_PERSONAL_DETAILS: { route: 'onboarding/personal-details', diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx index d33ff7a10b95..5a77d33453ba 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx @@ -70,6 +70,7 @@ import MultifactorAuthenticationModalNavigator from './Navigators/MultifactorAut import OnboardingModalNavigator from './Navigators/OnboardingModalNavigator'; import SubmitPlanWelcomeModalNavigator from './Navigators/SubmitPlanWelcomeModalNavigator'; import TestToolsModalNavigator from './Navigators/TestToolsModalNavigator'; +import SubmitIntentDeeplinkHandler from './SubmitIntentDeeplinkHandler'; import TestDriveDemoNavigator from './TestDriveDemoNavigator'; import ThreeDSAuthHandler from './ThreeDSAuthHandler'; import useModalCardStyleInterpolator from './useModalCardStyleInterpolator'; @@ -164,6 +165,7 @@ function AuthScreens() { return ( <> + diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx new file mode 100644 index 000000000000..1a7fb0a58a80 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -0,0 +1,55 @@ +import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useOnyx from '@hooks/useOnyx'; + +import {setSubmitMigrationModalShown} from '@userActions/User'; +import {setOnboardingPurposeSelected} from '@userActions/Welcome'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; +import {isSupportalSessionSelector} from '@selectors/Session'; +import {useEffect, useRef} from 'react'; + +/** + * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. + * + * Only rendered once the deeplink has been recognised, so the Onyx subscriptions behind + * `useAutoCreateSubmitWorkspace` are never set up for ordinary sessions. + */ +function ApplySubmitOnboardingIntent() { + const {firstName, lastName} = useCurrentUserPersonalDetails(); + const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); + + // HAS_LOADED_APP only flips true once this session's account data has landed, so waiting on it keeps the + // eligibility checks inside useAutoCreateSubmitWorkspace (existing workspaces, restricted policy creation) + // from running against a half-populated store and creating a duplicate workspace. + const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); + const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); + const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); + + const hasAppliedIntent = useRef(false); + + useEffect(() => { + if (hasAppliedIntent.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { + return; + } + hasAppliedIntent.current = true; + + setOnboardingPurposeSelected(CONST.ONBOARDING_CHOICES.EMPLOYER); + + // The deeplink delivers the same outcome as the Submit plan welcome modal, so record the modal as seen to + // stop it from opening on top of the workspace we're about to create. + setSubmitMigrationModalShown(); + + // Users who already finished onboarding must not run guided setup again. When they already own a Submit + // workspace, useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is + // what makes repeat clicks of the link idempotent. + autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', !isOnboardingCompleted); + }, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]); + + return null; +} + +export default ApplySubmitOnboardingIntent; diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx new file mode 100644 index 000000000000..8ad450289ed6 --- /dev/null +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx @@ -0,0 +1,32 @@ +import {useInitialURLState} from '@components/InitialURLContextProvider'; + +import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; + +import CONST from '@src/CONST'; + +import React, {useState} from 'react'; + +import ApplySubmitOnboardingIntent from './ApplySubmitOnboardingIntent'; + +/** + * Recognises the `intent=submit` onboarding deeplink and hands off to the component that acts on it. + * + * Both deeplink sources have to be read, and the browser one has to be frozen at mount: on web the URL is rewritten + * as soon as the app navigates away from the handoff route, while on native it is empty and the initial URL instead + * resolves asynchronously after this component mounts. + */ +function SubmitIntentDeeplinkHandler() { + const {initialURL} = useInitialURLState(); + const [urlAtMount] = useState(getCurrentUrl); + + const hasSubmitIntent = getOnboardingIntentFromUrl(urlAtMount) === CONST.ONBOARDING_INTENTS.SUBMIT || getOnboardingIntentFromUrl(initialURL) === CONST.ONBOARDING_INTENTS.SUBMIT; + + if (!hasSubmitIntent) { + return null; + } + + return ; +} + +export default SubmitIntentDeeplinkHandler; diff --git a/src/libs/getOnboardingIntentFromUrl.ts b/src/libs/getOnboardingIntentFromUrl.ts new file mode 100644 index 000000000000..fc54ba5d2c73 --- /dev/null +++ b/src/libs/getOnboardingIntentFromUrl.ts @@ -0,0 +1,47 @@ +/** + * Reads the `intent` param of the onboarding deeplink (e.g. `onboarding?intent=submit`), which lets a one-click + * link pre-select an onboarding outcome instead of asking the recipient to pick it in the UI. + * + * The param arrives in one of two shapes: + * - directly, when the recipient is already signed in: `/onboarding?intent=submit` + * - nested in the `exitTo` of an auth handoff link: `/transition?...&exitTo=onboarding%3Fintent%3Dsubmit` or + * `/v//?exitTo=onboarding%3Fintent%3Dsubmit` + * + * Nesting it in `exitTo` is what carries the intent across the logged-out -> logged-in transition: the deeplink + * outlives the sign-in itself, so the intent is still readable once the authenticated screens mount. + */ +import type {OnboardingIntent} from '@src/CONST'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; + +import {getSearchParamFromPath} from './Url'; + +const ONBOARDING_INTENT_VALUES = new Set(Object.values(CONST.ONBOARDING_INTENTS)); + +function isOnboardingIntent(value: string | null): value is OnboardingIntent { + return !!value && ONBOARDING_INTENT_VALUES.has(value); +} + +/** Strips the scheme and host so absolute URLs and in-app paths can be inspected the same way. */ +function getPathWithQuery(url: string): string { + const [withoutHash] = url.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '').split('#', 2); + return withoutHash.replace(/^\/+/, ''); +} + +function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined { + if (!url) { + return undefined; + } + + const pathWithQuery = getPathWithQuery(url); + const onboardingPathWithQuery = pathWithQuery.startsWith(ROUTES.ONBOARDING_ROOT.route) ? pathWithQuery : getSearchParamFromPath(pathWithQuery, 'exitTo'); + + if (!onboardingPathWithQuery?.startsWith(ROUTES.ONBOARDING_ROOT.route)) { + return undefined; + } + + const intent = getSearchParamFromPath(onboardingPathWithQuery, 'intent'); + return isOnboardingIntent(intent) ? intent : undefined; +} + +export default getOnboardingIntentFromUrl; diff --git a/tests/unit/getOnboardingIntentFromUrlTest.ts b/tests/unit/getOnboardingIntentFromUrlTest.ts new file mode 100644 index 000000000000..60f8ee6877b3 --- /dev/null +++ b/tests/unit/getOnboardingIntentFromUrlTest.ts @@ -0,0 +1,42 @@ +import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; + +import CONST from '@src/CONST'; + +describe('getOnboardingIntentFromUrl', () => { + it('reads the intent from a direct onboarding link', () => { + expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from an in-app path without an origin', () => { + expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from the exitTo of an OldDot transition link', () => { + const url = 'https://new.expensify.com/transition?email=me%40example.com&shortLivedAuthToken=abc123&exitTo=onboarding%3Fintent%3Dsubmit'; + + expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from the exitTo of a magic link', () => { + const url = 'https://new.expensify.com/v/12345/678910?exitTo=onboarding%3Fintent%3Dsubmit'; + + expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from an unencoded exitTo, which OldDot mobile does not encode', () => { + const url = 'https://new.expensify.com/transition?shortLivedAuthToken=abc123&exitTo=onboarding?intent=submit'; + + expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it.each([ + ['no url', undefined], + ['an empty url', ''], + ['an onboarding link without an intent', 'https://new.expensify.com/onboarding'], + ['an unknown intent value', 'https://new.expensify.com/onboarding?intent=notARealIntent'], + ['an intent on a non-onboarding route', 'https://new.expensify.com/settings/profile?intent=submit'], + ['an intent on a non-onboarding exitTo', 'https://new.expensify.com/transition?exitTo=workspace%2Fnew%3Fintent%3Dsubmit'], + ])('returns undefined for %s', (_description, url) => { + expect(getOnboardingIntentFromUrl(url)).toBeUndefined(); + }); +}); From 1bf9098ba11a94f742483f14c73fffa49c3b7092 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 18 Aug 2026 17:21:23 +0200 Subject: [PATCH 02/13] Route the Submit deeplink through onboarding instead of racing it Creating the Submit workspace out-of-band left users stranded: the onboarding navigator mounts straight from the deeplink URL and picks its entry step from the account's domain, so it settled on the work email step before the handler ran, and startOnboardingFlow cannot re-route a navigator that is already in the root state. The workspace ended up orphaned behind a modal the user could not get past. Let the deeplink pick the onboarding entry step instead, so the existing EMPLOYER path creates the workspace and nothing competes for navigation. Users who already finished onboarding never enter that flow, so for them the workspace is still created directly. --- src/ONYXKEYS.ts | 6 ++- src/hooks/useAutoCreateSubmitWorkspace.ts | 5 ++- src/hooks/useOnboardingDeeplinkIntent.ts | 29 +++++++++++++ src/hooks/useOnboardingFlow.ts | 4 ++ .../Navigators/OnboardingModalNavigator.tsx | 9 ++++ .../ApplySubmitOnboardingIntent.tsx | 41 +++++++++++++------ .../SubmitIntentDeeplinkHandler/index.tsx | 18 ++------ src/libs/Navigation/guards/OnboardingGuard.ts | 12 ++++++ src/libs/actions/Welcome/OnboardingFlow.ts | 12 ++++++ src/libs/actions/Welcome/index.ts | 11 ++++- tests/unit/OnboardingFlowTest.ts | 14 +++++++ 11 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 src/hooks/useOnboardingDeeplinkIntent.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 08c248fd7e6a..5b4e03112f3a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; -import type {OnboardingAccounting} from './CONST'; +import type {OnboardingAccounting, OnboardingIntent} from './CONST'; import type {TranslationPaths} from './languages/types'; import type {OnboardingFeatureMapItem} from './libs/actions/Welcome/OnboardingFeatures'; import type {OnboardingCompanySize} from './libs/actions/Welcome/OnboardingFlow'; @@ -548,6 +548,9 @@ const ONYXKEYS = { /** Onboarding customized choices to display to the user based on their profile when signing up */ ONBOARDING_CUSTOM_CHOICES: 'onboardingCustomChoices', + /** Onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit` */ + ONBOARDING_DEEPLINK_INTENT: 'onboardingDeeplinkIntent', + /** Onboarding error message translation key to be displayed to the user */ ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY: 'onboardingErrorMessageTranslationKey', @@ -1710,6 +1713,7 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_COMPANY_SIZE]: OnboardingCompanySize; [ONYXKEYS.ONBOARDING_PERSONAL_TRACK_GOAL]: string; [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnyxTypes.OnboardingPurpose[] | []; + [ONYXKEYS.ONBOARDING_DEEPLINK_INTENT]: OnboardingIntent; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY]: TranslationPaths; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index c21193acf5fd..e0970b9a00b0 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -5,7 +5,7 @@ import {canEditWorkspaceSettings, isGroupPolicy, isSubmitPolicy} from '@libs/Pol import {createWorkspace, generateDefaultWorkspaceName, generatePolicyID} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; -import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; +import {setOnboardingAdminsChatReportID, setOnboardingDeeplinkIntent, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -116,6 +116,9 @@ function useAutoCreateSubmitWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); + // Both deeplink paths (the onboarding flow and the already-onboarded handler) end up here, so this is the + // one place that reliably retires a Submit deeplink intent once it has been honoured. + setOnboardingDeeplinkIntent(null); // Already-onboarded callers (the Submit plan welcome modal) can reach this point with no workspace // created and no onboarding policy ID when an editable Submit workspace already exists. Navigate to diff --git a/src/hooks/useOnboardingDeeplinkIntent.ts b/src/hooks/useOnboardingDeeplinkIntent.ts new file mode 100644 index 000000000000..f557ab7a48d3 --- /dev/null +++ b/src/hooks/useOnboardingDeeplinkIntent.ts @@ -0,0 +1,29 @@ +import {useInitialURLState} from '@components/InitialURLContextProvider'; + +import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; +import getCurrentUrl from '@libs/Navigation/currentUrl'; + +import type {OnboardingIntent} from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import {useState} from 'react'; + +import useOnyx from './useOnyx'; + +/** + * Resolves the onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit`. + * + * All three sources are needed. The stored copy is the durable one but is written asynchronously, so it is absent + * during the first render, which is exactly when the onboarding navigator picks its entry step. The browser URL covers + * that first render but is rewritten as soon as the flow navigates, hence the mount-time latch. The initial URL is the + * only source on native, where the browser URL is empty and the deeplink resolves asynchronously. + */ +function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined { + const {initialURL} = useInitialURLState(); + const [urlAtMount] = useState(getCurrentUrl); + const [storedIntent] = useOnyx(ONYXKEYS.ONBOARDING_DEEPLINK_INTENT); + + return storedIntent ?? getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL); +} + +export default useOnboardingDeeplinkIntent; diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index e4a37369143a..7675b988be5e 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -19,6 +19,7 @@ import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector, wasInvitedTo import {emailSelector} from '@selectors/Session'; import {useCallback, useEffect} from 'react'; +import useOnboardingDeeplinkIntent from './useOnboardingDeeplinkIntent'; import useOnyx from './useOnyx'; import useShouldSuppressPromotionalUI from './useShouldSuppressPromotionalUI'; @@ -53,6 +54,7 @@ function useOnboardingFlowRouter() { const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); + const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const wasInvitedToNewDot = wasInvitedToNewDotSelector(introSelected); @@ -131,6 +133,7 @@ function useOnboardingFlowRouter() { onboardingInitialPath, onboardingValues, isAccountValidated: !!account?.validated, + onboardingDeeplinkIntent, }); }); } @@ -158,6 +161,7 @@ function useOnboardingFlowRouter() { onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath, + onboardingDeeplinkIntent, hasBeenAddedToNudgeMigration, hasNonPersonalPolicy, wasInvitedToNewDot, diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index ba74ee04a5e3..69a8b0a02de1 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -2,6 +2,7 @@ import NoDropZone from '@components/DragAndDrop/NoDropZone'; import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; +import useOnboardingDeeplinkIntent from '@hooks/useOnboardingDeeplinkIntent'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -51,6 +52,7 @@ function OnboardingModalNavigator() { const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const outerViewRef = React.useRef(null); const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT); + const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); const isOnPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && account?.hasAccessibleDomainPolicies; let initialRouteName: ValueOf = SCREENS.ONBOARDING.PURPOSE; @@ -63,6 +65,13 @@ function OnboardingModalNavigator() { initialRouteName = SCREENS.ONBOARDING.WORK_EMAIL; } + // A Submit deeplink already answers the purpose question, so it enters at the step that follows it. This has to be + // decided here rather than by navigating afterwards: the navigator mounts straight from the deeplink URL, and once + // it is in the root state startOnboardingFlow can no longer change which step is showing. + if (onboardingDeeplinkIntent === CONST.ONBOARDING_INTENTS.SUBMIT) { + initialRouteName = SCREENS.ONBOARDING.PERSONAL_DETAILS; + } + const [accountID] = useOnyx(ONYXKEYS.SESSION, { selector: accountIDSelector, }); diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx index 1a7fb0a58a80..7aebdee62985 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -3,7 +3,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useOnyx from '@hooks/useOnyx'; import {setSubmitMigrationModalShown} from '@userActions/User'; -import {setOnboardingPurposeSelected} from '@userActions/Welcome'; +import {setOnboardingDeeplinkIntent} from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -13,10 +13,14 @@ import {isSupportalSessionSelector} from '@selectors/Session'; import {useEffect, useRef} from 'react'; /** - * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. + * Acts on an `intent=submit` onboarding deeplink. * - * Only rendered once the deeplink has been recognised, so the Onyx subscriptions behind - * `useAutoCreateSubmitWorkspace` are never set up for ordinary sessions. + * Recording the intent is what drives users who still have onboarding ahead of them: the onboarding flow reads it and + * routes them down the EMPLOYER path, which creates the Submit workspace at the end. Users who already finished + * onboarding never enter that flow, so for them the workspace is created here instead. + * + * Only rendered once the deeplink has been recognised, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace` + * are never set up for ordinary sessions. */ function ApplySubmitOnboardingIntent() { const {firstName, lastName} = useCurrentUserPersonalDetails(); @@ -29,24 +33,37 @@ function ApplySubmitOnboardingIntent() { const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); - const hasAppliedIntent = useRef(false); + const hasDecided = useRef(false); useEffect(() => { - if (hasAppliedIntent.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { + if (isSupportalSession) { return; } - hasAppliedIntent.current = true; + setOnboardingDeeplinkIntent(CONST.ONBOARDING_INTENTS.SUBMIT); + }, [isSupportalSession]); - setOnboardingPurposeSelected(CONST.ONBOARDING_CHOICES.EMPLOYER); + useEffect(() => { + if (hasDecided.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { + return; + } + // Decided once and for all on this first complete read. Without that, finishing guided setup would flip + // isOnboardingCompleted and re-enter this branch on top of the workspace the flow just created. + hasDecided.current = true; + + // Users who still have onboarding ahead of them get their Submit workspace from the flow itself, which reads + // the intent recorded above. Only users who will never enter that flow need it created here. + if (!isOnboardingCompleted) { + return; + } // The deeplink delivers the same outcome as the Submit plan welcome modal, so record the modal as seen to // stop it from opening on top of the workspace we're about to create. setSubmitMigrationModalShown(); - // Users who already finished onboarding must not run guided setup again. When they already own a Submit - // workspace, useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is - // what makes repeat clicks of the link idempotent. - autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', !isOnboardingCompleted); + // Guided setup is already done, so it must not run again. When the user already owns a Submit workspace, + // useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is what makes + // repeat clicks of the link idempotent. + autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); }, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]); return null; diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx index 8ad450289ed6..32585fe9e3e1 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx @@ -1,28 +1,18 @@ -import {useInitialURLState} from '@components/InitialURLContextProvider'; - -import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; -import getCurrentUrl from '@libs/Navigation/currentUrl'; +import useOnboardingDeeplinkIntent from '@hooks/useOnboardingDeeplinkIntent'; import CONST from '@src/CONST'; -import React, {useState} from 'react'; +import React from 'react'; import ApplySubmitOnboardingIntent from './ApplySubmitOnboardingIntent'; /** * Recognises the `intent=submit` onboarding deeplink and hands off to the component that acts on it. - * - * Both deeplink sources have to be read, and the browser one has to be frozen at mount: on web the URL is rewritten - * as soon as the app navigates away from the handoff route, while on native it is empty and the initial URL instead - * resolves asynchronously after this component mounts. */ function SubmitIntentDeeplinkHandler() { - const {initialURL} = useInitialURLState(); - const [urlAtMount] = useState(getCurrentUrl); - - const hasSubmitIntent = getOnboardingIntentFromUrl(urlAtMount) === CONST.ONBOARDING_INTENTS.SUBMIT || getOnboardingIntentFromUrl(initialURL) === CONST.ONBOARDING_INTENTS.SUBMIT; + const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); - if (!hasSubmitIntent) { + if (onboardingDeeplinkIntent !== CONST.ONBOARDING_INTENTS.SUBMIT) { return null; } diff --git a/src/libs/Navigation/guards/OnboardingGuard.ts b/src/libs/Navigation/guards/OnboardingGuard.ts index d6fff22a1ecb..75bb69c7c4f5 100644 --- a/src/libs/Navigation/guards/OnboardingGuard.ts +++ b/src/libs/Navigation/guards/OnboardingGuard.ts @@ -5,6 +5,7 @@ import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; import CONFIG from '@src/CONFIG'; +import type {OnboardingIntent} from '@src/CONST'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -38,6 +39,7 @@ let hybridApp: {isSingleNewDotEntry?: boolean} | undefined; let onboardingPurposeSelected: OnyxEntry; let onboardingCompanySize: OnyxEntry; let onboardingInitialPath: OnyxEntry; +let onboardingDeeplinkIntent: OnyxEntry; let hasNonPersonalPolicy: OnyxEntry; let wasInvitedToNewDot: boolean | undefined; @@ -90,6 +92,15 @@ Onyx.connectWithoutView({ }, }); +// Without this the guard would recompute the onboarding route from progress alone and redirect a deeplinked user +// back to the step their intent was meant to skip. +Onyx.connectWithoutView({ + key: ONYXKEYS.ONBOARDING_DEEPLINK_INTENT, + callback: (value) => { + onboardingDeeplinkIntent = value; + }, +}); + Onyx.connectWithoutView({ key: ONYXKEYS.HAS_NON_PERSONAL_POLICY, callback: (value) => { @@ -117,6 +128,7 @@ function getOnboardingRoute(): Route { onboardingInitialPath, onboardingValues: onboarding, isAccountValidated: !!account?.validated, + onboardingDeeplinkIntent, }) as Route; } diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 7b1f67fa1d17..bfc62a4d7260 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -6,6 +6,7 @@ import type {RootNavigatorParamList} from '@libs/Navigation/types'; import type {Video} from '@userActions/Report'; +import type {OnboardingIntent} from '@src/CONST'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -34,6 +35,7 @@ type GetOnboardingInitialPathParamsType = { onboardingInitialPath: OnyxEntry | null; onboardingValues: OnyxEntry; isAccountValidated?: boolean; + onboardingDeeplinkIntent?: OnyxEntry; }; type OnboardingTaskLinks = Partial<{ @@ -113,6 +115,7 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI onboardingInitialPath, onboardingValues, isAccountValidated, + onboardingDeeplinkIntent, } = getOnboardingInitialPathParams; const initialPath = onboardingInitialPath ?? ''; const state = getStateFromPath(initialPath, linkingConfig.config); @@ -130,6 +133,15 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI if (isIndividual) { Onyx.set(ONYXKEYS.ONBOARDING_CUSTOM_CHOICES, [CONST.ONBOARDING_CHOICES.EMPLOYER, CONST.ONBOARDING_CHOICES.TRACK_BUSINESS, CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]); } + + // A Submit deeplink already answers the question the purpose step asks, so pre-select the intent and jump to the + // step that follows it. Personal details still have to be collected because the workspace is named after the user. + // From there the existing EMPLOYER path creates the Submit workspace, so the deeplink never races the onboarding + // flow for control of navigation. + if (onboardingDeeplinkIntent === CONST.ONBOARDING_INTENTS.SUBMIT) { + Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); + return `/${ROUTES.ONBOARDING_PERSONAL_DETAILS.route}`; + } // A validated account has no reason to be on the onboarding "add work email" screen. if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index 2c98d1722d4b..63147675ed2b 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -6,7 +6,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import CONFIG from '@src/CONFIG'; -import type {OnboardingAccounting} from '@src/CONST'; +import type {OnboardingAccounting, OnboardingIntent} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -52,6 +52,14 @@ function setOnboardingPurposeSelected(value: OnboardingPurpose) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } +/** + * Records the onboarding outcome requested by the deeplink this session was opened with, so the onboarding flow can + * route straight to it. Cleared once acted on, and by the Onyx wipe on sign-out. + */ +function setOnboardingDeeplinkIntent(value: OnboardingIntent | null) { + Onyx.set(ONYXKEYS.ONBOARDING_DEEPLINK_INTENT, value); +} + function setOnboardingCompanySize(value: OnboardingCompanySize) { Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, value); } @@ -217,6 +225,7 @@ export { onServerDataReady, dismissProductTraining, setOnboardingPurposeSelected, + setOnboardingDeeplinkIntent, updateOnboardingLastVisitedPath, resetAllChecks, setOnboardingAdminsChatReportID, diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index 3d3db8e7e663..a99e8bf5d4e1 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -43,6 +43,20 @@ describe('OnboardingFlow', () => { expect(path).toBe('/onboarding/personal-details'); }); + it('should skip ahead to personal details for a Submit deeplink, past the step it would otherwise land on', () => { + const params: GetOnboardingInitialPathParamsType = { + isUserFromPublicDomain: true, + hasAccessiblePolicies: false, + currentOnboardingPurposeSelected: undefined, + currentOnboardingCompanySize: undefined, + onboardingInitialPath: '', + onboardingValues: undefined, + onboardingDeeplinkIntent: CONST.ONBOARDING_INTENTS.SUBMIT, + }; + const path = getOnboardingInitialPath(params); + expect(path).toBe('/onboarding/personal-details'); + }); + it('should return the correct path for SMB', () => { const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: true, From 06e18f007939f4eee726076fd656c49318152a87 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 18 Aug 2026 18:33:40 +0200 Subject: [PATCH 03/13] Create the Submit workspace directly instead of routing through onboarding The link is going out to existing users, so dropping them into guided setup asks questions they have already answered. Skip the onboarding UI entirely and create the workspace outright, leaving them where the "Submit to my employer" flow normally lands: Spend with the #admins room in the side panel. Onboarding is suppressed for the session in both places that can start it, the router and the navigation guard, so nothing pulls the user into the flow while the workspace is being created. --- src/hooks/useOnboardingFlow.ts | 16 +++++++---- .../Navigators/OnboardingModalNavigator.tsx | 9 ------ .../ApplySubmitOnboardingIntent.tsx | 28 ++++++++----------- src/libs/Navigation/guards/OnboardingGuard.ts | 7 +++-- src/libs/actions/Welcome/OnboardingFlow.ts | 12 -------- tests/unit/OnboardingFlowTest.ts | 14 ---------- 6 files changed, 26 insertions(+), 60 deletions(-) diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index 7675b988be5e..ebfcc13fdccc 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -10,6 +10,7 @@ import {completeHybridAppOnboarding} from '@userActions/Welcome'; import {startOnboardingFlow} from '@userActions/Welcome/OnboardingFlow'; import CONFIG from '@src/CONFIG'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -54,7 +55,8 @@ function useOnboardingFlowRouter() { const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); - const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); + // A Submit deeplink creates the workspace outright, so there is nothing left for onboarding to ask. + const hasSubmitDeeplinkIntent = useOnboardingDeeplinkIntent() === CONST.ONBOARDING_INTENTS.SUBMIT; const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const wasInvitedToNewDot = wasInvitedToNewDotSelector(introSelected); @@ -80,6 +82,10 @@ function useOnboardingFlowRouter() { return; } + if (hasSubmitDeeplinkIntent) { + return; + } + if (isLoadingApp !== false || isOnboardingLoading) { return; } @@ -133,7 +139,6 @@ function useOnboardingFlowRouter() { onboardingInitialPath, onboardingValues, isAccountValidated: !!account?.validated, - onboardingDeeplinkIntent, }); }); } @@ -161,7 +166,7 @@ function useOnboardingFlowRouter() { onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath, - onboardingDeeplinkIntent, + hasSubmitDeeplinkIntent, hasBeenAddedToNudgeMigration, hasNonPersonalPolicy, wasInvitedToNewDot, @@ -171,8 +176,9 @@ function useOnboardingFlowRouter() { ]); return { - // Treat the flow as completed for secure-link visitors so the onboarding modal is not mounted over the report. - isOnboardingCompleted: isVisitingSecureLink ? true : hasCompletedGuidedSetupFlowSelector(onboardingValues), + // Treat the flow as completed for secure-link visitors so the onboarding modal is not mounted over the report, + // and for Submit deeplink visitors so it is not mounted over the workspace being created for them. + isOnboardingCompleted: isVisitingSecureLink || hasSubmitDeeplinkIntent ? true : hasCompletedGuidedSetupFlowSelector(onboardingValues), isHybridAppOnboardingCompleted, isOnboardingLoading: !!onboardingValues?.isLoading, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx index 69a8b0a02de1..ba74ee04a5e3 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/OnboardingModalNavigator.tsx @@ -2,7 +2,6 @@ import NoDropZone from '@components/DragAndDrop/NoDropZone'; import FocusTrapForScreens from '@components/FocusTrap/FocusTrapForScreen'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; -import useOnboardingDeeplinkIntent from '@hooks/useOnboardingDeeplinkIntent'; import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -52,7 +51,6 @@ function OnboardingModalNavigator() { const {onboardingIsMediumOrLargerScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout(); const outerViewRef = React.useRef(null); const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT); - const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); const isOnPrivateDomainAndHasAccessiblePolicies = !account?.isFromPublicDomain && account?.hasAccessibleDomainPolicies; let initialRouteName: ValueOf = SCREENS.ONBOARDING.PURPOSE; @@ -65,13 +63,6 @@ function OnboardingModalNavigator() { initialRouteName = SCREENS.ONBOARDING.WORK_EMAIL; } - // A Submit deeplink already answers the purpose question, so it enters at the step that follows it. This has to be - // decided here rather than by navigating afterwards: the navigator mounts straight from the deeplink URL, and once - // it is in the root state startOnboardingFlow can no longer change which step is showing. - if (onboardingDeeplinkIntent === CONST.ONBOARDING_INTENTS.SUBMIT) { - initialRouteName = SCREENS.ONBOARDING.PERSONAL_DETAILS; - } - const [accountID] = useOnyx(ONYXKEYS.SESSION, { selector: accountIDSelector, }); diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx index 7aebdee62985..08c10c89066d 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -13,11 +13,12 @@ import {isSupportalSessionSelector} from '@selectors/Session'; import {useEffect, useRef} from 'react'; /** - * Acts on an `intent=submit` onboarding deeplink. + * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. * - * Recording the intent is what drives users who still have onboarding ahead of them: the onboarding flow reads it and - * routes them down the EMPLOYER path, which creates the Submit workspace at the end. Users who already finished - * onboarding never enter that flow, so for them the workspace is created here instead. + * The link is sent to existing users, so it deliberately skips the onboarding UI entirely rather than pre-answering + * its questions: the workspace is created outright and the user lands wherever the "Submit to my employer" flow + * normally leaves them. Onboarding is suppressed for the whole session by useOnboardingFlowRouter and OnboardingGuard, + * both of which read the intent recorded here. * * Only rendered once the deeplink has been recognised, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace` * are never set up for ordinary sessions. @@ -33,7 +34,7 @@ function ApplySubmitOnboardingIntent() { const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); - const hasDecided = useRef(false); + const hasCreatedWorkspace = useRef(false); useEffect(() => { if (isSupportalSession) { @@ -43,27 +44,20 @@ function ApplySubmitOnboardingIntent() { }, [isSupportalSession]); useEffect(() => { - if (hasDecided.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { - return; - } - // Decided once and for all on this first complete read. Without that, finishing guided setup would flip - // isOnboardingCompleted and re-enter this branch on top of the workspace the flow just created. - hasDecided.current = true; - - // Users who still have onboarding ahead of them get their Submit workspace from the flow itself, which reads - // the intent recorded above. Only users who will never enter that flow need it created here. - if (!isOnboardingCompleted) { + if (hasCreatedWorkspace.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { return; } + hasCreatedWorkspace.current = true; // The deeplink delivers the same outcome as the Submit plan welcome modal, so record the modal as seen to // stop it from opening on top of the workspace we're about to create. setSubmitMigrationModalShown(); - // Guided setup is already done, so it must not run again. When the user already owns a Submit workspace, + // Recipients who never finished guided setup still need it marked complete, otherwise they would be pulled + // into onboarding on their next sign-in. When the user already owns a Submit workspace, // useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is what makes // repeat clicks of the link idempotent. - autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); + autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', !isOnboardingCompleted); }, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]); return null; diff --git a/src/libs/Navigation/guards/OnboardingGuard.ts b/src/libs/Navigation/guards/OnboardingGuard.ts index 75bb69c7c4f5..e002dd23776f 100644 --- a/src/libs/Navigation/guards/OnboardingGuard.ts +++ b/src/libs/Navigation/guards/OnboardingGuard.ts @@ -92,8 +92,7 @@ Onyx.connectWithoutView({ }, }); -// Without this the guard would recompute the onboarding route from progress alone and redirect a deeplinked user -// back to the step their intent was meant to skip. +// A Submit deeplink creates the workspace outright, so the guard must not pull the user into onboarding on the way. Onyx.connectWithoutView({ key: ONYXKEYS.ONBOARDING_DEEPLINK_INTENT, callback: (value) => { @@ -128,7 +127,6 @@ function getOnboardingRoute(): Route { onboardingInitialPath, onboardingValues: onboarding, isAccountValidated: !!account?.validated, - onboardingDeeplinkIntent, }) as Route; } @@ -188,6 +186,7 @@ const OnboardingGuard: NavigationGuard = { const isMigratedUser = tryNewDot?.hasBeenAddedToNudgeMigration ?? false; const isSingleEntry = hybridApp?.isSingleNewDotEntry ?? false; const isFirstTimeHybridAppTransition = (CONFIG.IS_HYBRID_APP && tryNewDot?.isHybridAppOnboardingCompleted !== true) ?? false; + const hasSubmitDeeplinkIntent = onboardingDeeplinkIntent === CONST.ONBOARDING_INTENTS.SUBMIT; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isInvitedOrGroupMember = (hasNonPersonalPolicy || wasInvitedToNewDot) ?? false; @@ -221,6 +220,7 @@ const OnboardingGuard: NavigationGuard = { isSingleEntry || isFirstTimeHybridAppTransition || isNavigatingWithReplace || + hasSubmitDeeplinkIntent || context.isSupportalSession || // Copilots should not be pushed through onboarding on behalf of the account they are accessing isActingAsDelegateSelector(account); @@ -251,6 +251,7 @@ const OnboardingGuard: NavigationGuard = { isFirstTimeHybridAppTransition, isInvitedOrGroupMember, isNavigatingWithReplace, + hasSubmitDeeplinkIntent, }); return { diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index bfc62a4d7260..7b1f67fa1d17 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -6,7 +6,6 @@ import type {RootNavigatorParamList} from '@libs/Navigation/types'; import type {Video} from '@userActions/Report'; -import type {OnboardingIntent} from '@src/CONST'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -35,7 +34,6 @@ type GetOnboardingInitialPathParamsType = { onboardingInitialPath: OnyxEntry | null; onboardingValues: OnyxEntry; isAccountValidated?: boolean; - onboardingDeeplinkIntent?: OnyxEntry; }; type OnboardingTaskLinks = Partial<{ @@ -115,7 +113,6 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI onboardingInitialPath, onboardingValues, isAccountValidated, - onboardingDeeplinkIntent, } = getOnboardingInitialPathParams; const initialPath = onboardingInitialPath ?? ''; const state = getStateFromPath(initialPath, linkingConfig.config); @@ -133,15 +130,6 @@ function getOnboardingInitialPath(getOnboardingInitialPathParams: GetOnboardingI if (isIndividual) { Onyx.set(ONYXKEYS.ONBOARDING_CUSTOM_CHOICES, [CONST.ONBOARDING_CHOICES.EMPLOYER, CONST.ONBOARDING_CHOICES.TRACK_BUSINESS, CONST.ONBOARDING_CHOICES.TRACK_PERSONAL]); } - - // A Submit deeplink already answers the question the purpose step asks, so pre-select the intent and jump to the - // step that follows it. Personal details still have to be collected because the workspace is named after the user. - // From there the existing EMPLOYER path creates the Submit workspace, so the deeplink never races the onboarding - // flow for control of navigation. - if (onboardingDeeplinkIntent === CONST.ONBOARDING_INTENTS.SUBMIT) { - Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, CONST.ONBOARDING_CHOICES.EMPLOYER); - return `/${ROUTES.ONBOARDING_PERSONAL_DETAILS.route}`; - } // A validated account has no reason to be on the onboarding "add work email" screen. if (isUserFromPublicDomain && !onboardingValuesParam?.isMergeAccountStepCompleted && !isAccountValidated) { return `/${ROUTES.ONBOARDING_WORK_EMAIL.route}`; diff --git a/tests/unit/OnboardingFlowTest.ts b/tests/unit/OnboardingFlowTest.ts index a99e8bf5d4e1..3d3db8e7e663 100644 --- a/tests/unit/OnboardingFlowTest.ts +++ b/tests/unit/OnboardingFlowTest.ts @@ -43,20 +43,6 @@ describe('OnboardingFlow', () => { expect(path).toBe('/onboarding/personal-details'); }); - it('should skip ahead to personal details for a Submit deeplink, past the step it would otherwise land on', () => { - const params: GetOnboardingInitialPathParamsType = { - isUserFromPublicDomain: true, - hasAccessiblePolicies: false, - currentOnboardingPurposeSelected: undefined, - currentOnboardingCompanySize: undefined, - onboardingInitialPath: '', - onboardingValues: undefined, - onboardingDeeplinkIntent: CONST.ONBOARDING_INTENTS.SUBMIT, - }; - const path = getOnboardingInitialPath(params); - expect(path).toBe('/onboarding/personal-details'); - }); - it('should return the correct path for SMB', () => { const params: GetOnboardingInitialPathParamsType = { isUserFromPublicDomain: true, From f843d868c86feb887a09715ea978b302ca217d7d Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 18 Aug 2026 18:53:55 +0200 Subject: [PATCH 04/13] Act on the Submit deeplink only for users who already onboarded The link is scoped to existing users with an intent already set, so anyone who still has guided setup ahead of them is left to the normal onboarding flow, which already offers the Submit outcome. That removes the need to suppress onboarding at all, so the router, the navigation guard and the shared workspace-creation hook go back to their original behaviour. Also corrects a stale comment describing the post-creation destination as Categories; the shared helper navigates to Spend with #admins in the side panel. --- src/ONYXKEYS.ts | 6 +--- src/components/SubmitPlanWelcomeModal.tsx | 4 +-- src/hooks/useAutoCreateSubmitWorkspace.ts | 5 +--- src/hooks/useOnboardingDeeplinkIntent.ts | 13 +++------ src/hooks/useOnboardingFlow.ts | 14 ++-------- .../ApplySubmitOnboardingIntent.tsx | 28 ++++++++----------- src/libs/Navigation/guards/OnboardingGuard.ts | 13 --------- src/libs/actions/Welcome/index.ts | 11 +------- 8 files changed, 23 insertions(+), 71 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 5b4e03112f3a..08c248fd7e6a 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'type-fest'; import type CONST from './CONST'; -import type {OnboardingAccounting, OnboardingIntent} from './CONST'; +import type {OnboardingAccounting} from './CONST'; import type {TranslationPaths} from './languages/types'; import type {OnboardingFeatureMapItem} from './libs/actions/Welcome/OnboardingFeatures'; import type {OnboardingCompanySize} from './libs/actions/Welcome/OnboardingFlow'; @@ -548,9 +548,6 @@ const ONYXKEYS = { /** Onboarding customized choices to display to the user based on their profile when signing up */ ONBOARDING_CUSTOM_CHOICES: 'onboardingCustomChoices', - /** Onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit` */ - ONBOARDING_DEEPLINK_INTENT: 'onboardingDeeplinkIntent', - /** Onboarding error message translation key to be displayed to the user */ ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY: 'onboardingErrorMessageTranslationKey', @@ -1713,7 +1710,6 @@ type OnyxValuesMapping = { [ONYXKEYS.ONBOARDING_COMPANY_SIZE]: OnboardingCompanySize; [ONYXKEYS.ONBOARDING_PERSONAL_TRACK_GOAL]: string; [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnyxTypes.OnboardingPurpose[] | []; - [ONYXKEYS.ONBOARDING_DEEPLINK_INTENT]: OnboardingIntent; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE_TRANSLATION_KEY]: TranslationPaths; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; diff --git a/src/components/SubmitPlanWelcomeModal.tsx b/src/components/SubmitPlanWelcomeModal.tsx index 2a9c62237503..4e9394cac5cb 100644 --- a/src/components/SubmitPlanWelcomeModal.tsx +++ b/src/components/SubmitPlanWelcomeModal.tsx @@ -49,8 +49,8 @@ function SubmitPlanWelcomeModal() { const handleConfirm = () => { // The user has already completed onboarding, so we skip CompleteGuidedSetup and just create the - // Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Categories - // with #admins in the RHP, which triggers the useBeforeRemove persistence above. + // Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Spend + // with #admins in the side panel, which triggers the useBeforeRemove persistence above. autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); }; diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index e0970b9a00b0..c21193acf5fd 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -5,7 +5,7 @@ import {canEditWorkspaceSettings, isGroupPolicy, isSubmitPolicy} from '@libs/Pol import {createWorkspace, generateDefaultWorkspaceName, generatePolicyID} from '@userActions/Policy/Policy'; import {completeOnboarding} from '@userActions/Report'; -import {setOnboardingAdminsChatReportID, setOnboardingDeeplinkIntent, setOnboardingPolicyID} from '@userActions/Welcome'; +import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -116,9 +116,6 @@ function useAutoCreateSubmitWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); - // Both deeplink paths (the onboarding flow and the already-onboarded handler) end up here, so this is the - // one place that reliably retires a Submit deeplink intent once it has been honoured. - setOnboardingDeeplinkIntent(null); // Already-onboarded callers (the Submit plan welcome modal) can reach this point with no workspace // created and no onboarding policy ID when an editable Submit workspace already exists. Navigate to diff --git a/src/hooks/useOnboardingDeeplinkIntent.ts b/src/hooks/useOnboardingDeeplinkIntent.ts index f557ab7a48d3..90ee458f5ec0 100644 --- a/src/hooks/useOnboardingDeeplinkIntent.ts +++ b/src/hooks/useOnboardingDeeplinkIntent.ts @@ -4,26 +4,21 @@ import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import type {OnboardingIntent} from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import {useState} from 'react'; -import useOnyx from './useOnyx'; - /** * Resolves the onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit`. * - * All three sources are needed. The stored copy is the durable one but is written asynchronously, so it is absent - * during the first render, which is exactly when the onboarding navigator picks its entry step. The browser URL covers - * that first render but is rewritten as soon as the flow navigates, hence the mount-time latch. The initial URL is the - * only source on native, where the browser URL is empty and the deeplink resolves asynchronously. + * The URL is latched at mount because the app rewrites it as soon as it navigates, which happens well before the + * intent has been acted on. The initial URL is the only source on native, where the browser URL is empty and the + * deeplink resolves asynchronously. */ function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined { const {initialURL} = useInitialURLState(); const [urlAtMount] = useState(getCurrentUrl); - const [storedIntent] = useOnyx(ONYXKEYS.ONBOARDING_DEEPLINK_INTENT); - return storedIntent ?? getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL); + return getOnboardingIntentFromUrl(urlAtMount) ?? getOnboardingIntentFromUrl(initialURL); } export default useOnboardingDeeplinkIntent; diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index ebfcc13fdccc..e4a37369143a 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -10,7 +10,6 @@ import {completeHybridAppOnboarding} from '@userActions/Welcome'; import {startOnboardingFlow} from '@userActions/Welcome/OnboardingFlow'; import CONFIG from '@src/CONFIG'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -20,7 +19,6 @@ import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector, wasInvitedTo import {emailSelector} from '@selectors/Session'; import {useCallback, useEffect} from 'react'; -import useOnboardingDeeplinkIntent from './useOnboardingDeeplinkIntent'; import useOnyx from './useOnyx'; import useShouldSuppressPromotionalUI from './useShouldSuppressPromotionalUI'; @@ -55,8 +53,6 @@ function useOnboardingFlowRouter() { const [onboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED); const [onboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH); - // A Submit deeplink creates the workspace outright, so there is nothing left for onboarding to ask. - const hasSubmitDeeplinkIntent = useOnboardingDeeplinkIntent() === CONST.ONBOARDING_INTENTS.SUBMIT; const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const wasInvitedToNewDot = wasInvitedToNewDotSelector(introSelected); @@ -82,10 +78,6 @@ function useOnboardingFlowRouter() { return; } - if (hasSubmitDeeplinkIntent) { - return; - } - if (isLoadingApp !== false || isOnboardingLoading) { return; } @@ -166,7 +158,6 @@ function useOnboardingFlowRouter() { onboardingCompanySize, onboardingPurposeSelected, onboardingInitialPath, - hasSubmitDeeplinkIntent, hasBeenAddedToNudgeMigration, hasNonPersonalPolicy, wasInvitedToNewDot, @@ -176,9 +167,8 @@ function useOnboardingFlowRouter() { ]); return { - // Treat the flow as completed for secure-link visitors so the onboarding modal is not mounted over the report, - // and for Submit deeplink visitors so it is not mounted over the workspace being created for them. - isOnboardingCompleted: isVisitingSecureLink || hasSubmitDeeplinkIntent ? true : hasCompletedGuidedSetupFlowSelector(onboardingValues), + // Treat the flow as completed for secure-link visitors so the onboarding modal is not mounted over the report. + isOnboardingCompleted: isVisitingSecureLink ? true : hasCompletedGuidedSetupFlowSelector(onboardingValues), isHybridAppOnboardingCompleted, isOnboardingLoading: !!onboardingValues?.isLoading, }; diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx index 08c10c89066d..dbdb30f3a702 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -3,9 +3,7 @@ import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails' import useOnyx from '@hooks/useOnyx'; import {setSubmitMigrationModalShown} from '@userActions/User'; -import {setOnboardingDeeplinkIntent} from '@userActions/Welcome'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; @@ -15,10 +13,10 @@ import {useEffect, useRef} from 'react'; /** * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. * - * The link is sent to existing users, so it deliberately skips the onboarding UI entirely rather than pre-answering - * its questions: the workspace is created outright and the user lands wherever the "Submit to my employer" flow - * normally leaves them. Onboarding is suppressed for the whole session by useOnboardingFlowRouter and OnboardingGuard, - * both of which read the intent recorded here. + * The link is only sent to existing users, so it acts solely on recipients who have already been through guided + * setup: for them the workspace is created outright and they land wherever the "Submit to my employer" flow normally + * leaves them. Anyone who still has onboarding ahead of them is left to it untouched, since that flow already offers + * the Submit outcome. * * Only rendered once the deeplink has been recognised, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace` * are never set up for ordinary sessions. @@ -34,30 +32,28 @@ function ApplySubmitOnboardingIntent() { const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); - const hasCreatedWorkspace = useRef(false); + const hasRun = useRef(false); useEffect(() => { - if (isSupportalSession) { + if (hasRun.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { return; } - setOnboardingDeeplinkIntent(CONST.ONBOARDING_INTENTS.SUBMIT); - }, [isSupportalSession]); + hasRun.current = true; - useEffect(() => { - if (hasCreatedWorkspace.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { + // Recipients who never finished guided setup are left to the normal onboarding flow, which already offers + // the Submit outcome. + if (!isOnboardingCompleted) { return; } - hasCreatedWorkspace.current = true; // The deeplink delivers the same outcome as the Submit plan welcome modal, so record the modal as seen to // stop it from opening on top of the workspace we're about to create. setSubmitMigrationModalShown(); - // Recipients who never finished guided setup still need it marked complete, otherwise they would be pulled - // into onboarding on their next sign-in. When the user already owns a Submit workspace, + // Guided setup is already done, so it must not run again. When the user already owns a Submit workspace, // useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is what makes // repeat clicks of the link idempotent. - autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', !isOnboardingCompleted); + autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); }, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]); return null; diff --git a/src/libs/Navigation/guards/OnboardingGuard.ts b/src/libs/Navigation/guards/OnboardingGuard.ts index e002dd23776f..d6fff22a1ecb 100644 --- a/src/libs/Navigation/guards/OnboardingGuard.ts +++ b/src/libs/Navigation/guards/OnboardingGuard.ts @@ -5,7 +5,6 @@ import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; import CONFIG from '@src/CONFIG'; -import type {OnboardingIntent} from '@src/CONST'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -39,7 +38,6 @@ let hybridApp: {isSingleNewDotEntry?: boolean} | undefined; let onboardingPurposeSelected: OnyxEntry; let onboardingCompanySize: OnyxEntry; let onboardingInitialPath: OnyxEntry; -let onboardingDeeplinkIntent: OnyxEntry; let hasNonPersonalPolicy: OnyxEntry; let wasInvitedToNewDot: boolean | undefined; @@ -92,14 +90,6 @@ Onyx.connectWithoutView({ }, }); -// A Submit deeplink creates the workspace outright, so the guard must not pull the user into onboarding on the way. -Onyx.connectWithoutView({ - key: ONYXKEYS.ONBOARDING_DEEPLINK_INTENT, - callback: (value) => { - onboardingDeeplinkIntent = value; - }, -}); - Onyx.connectWithoutView({ key: ONYXKEYS.HAS_NON_PERSONAL_POLICY, callback: (value) => { @@ -186,7 +176,6 @@ const OnboardingGuard: NavigationGuard = { const isMigratedUser = tryNewDot?.hasBeenAddedToNudgeMigration ?? false; const isSingleEntry = hybridApp?.isSingleNewDotEntry ?? false; const isFirstTimeHybridAppTransition = (CONFIG.IS_HYBRID_APP && tryNewDot?.isHybridAppOnboardingCompleted !== true) ?? false; - const hasSubmitDeeplinkIntent = onboardingDeeplinkIntent === CONST.ONBOARDING_INTENTS.SUBMIT; // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isInvitedOrGroupMember = (hasNonPersonalPolicy || wasInvitedToNewDot) ?? false; @@ -220,7 +209,6 @@ const OnboardingGuard: NavigationGuard = { isSingleEntry || isFirstTimeHybridAppTransition || isNavigatingWithReplace || - hasSubmitDeeplinkIntent || context.isSupportalSession || // Copilots should not be pushed through onboarding on behalf of the account they are accessing isActingAsDelegateSelector(account); @@ -251,7 +239,6 @@ const OnboardingGuard: NavigationGuard = { isFirstTimeHybridAppTransition, isInvitedOrGroupMember, isNavigatingWithReplace, - hasSubmitDeeplinkIntent, }); return { diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index 63147675ed2b..2c98d1722d4b 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -6,7 +6,7 @@ import Log from '@libs/Log'; import Navigation from '@libs/Navigation/Navigation'; import CONFIG from '@src/CONFIG'; -import type {OnboardingAccounting, OnboardingIntent} from '@src/CONST'; +import type {OnboardingAccounting} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -52,14 +52,6 @@ function setOnboardingPurposeSelected(value: OnboardingPurpose) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } -/** - * Records the onboarding outcome requested by the deeplink this session was opened with, so the onboarding flow can - * route straight to it. Cleared once acted on, and by the Onyx wipe on sign-out. - */ -function setOnboardingDeeplinkIntent(value: OnboardingIntent | null) { - Onyx.set(ONYXKEYS.ONBOARDING_DEEPLINK_INTENT, value); -} - function setOnboardingCompanySize(value: OnboardingCompanySize) { Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, value); } @@ -225,7 +217,6 @@ export { onServerDataReady, dismissProductTraining, setOnboardingPurposeSelected, - setOnboardingDeeplinkIntent, updateOnboardingLastVisitedPath, resetAllChecks, setOnboardingAdminsChatReportID, From 8b21e7156b2d4e9aed7633b397f8a17a6b310d5b Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 18 Aug 2026 19:03:58 +0200 Subject: [PATCH 05/13] Fix spellcheck failures in deeplink comments Use US spelling for "recognize" and drop the percent-encoded exitTo examples, which cspell reads as the unknown words "Fintent" and "Dsubmit". --- .../ApplySubmitOnboardingIntent.tsx | 2 +- .../AppNavigator/SubmitIntentDeeplinkHandler/index.tsx | 2 +- src/libs/getOnboardingIntentFromUrl.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx index dbdb30f3a702..4d1dc1887d41 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -18,7 +18,7 @@ import {useEffect, useRef} from 'react'; * leaves them. Anyone who still has onboarding ahead of them is left to it untouched, since that flow already offers * the Submit outcome. * - * Only rendered once the deeplink has been recognised, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace` + * Only rendered once the deeplink has been recognized, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace` * are never set up for ordinary sessions. */ function ApplySubmitOnboardingIntent() { diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx index 32585fe9e3e1..436403abd3de 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/index.tsx @@ -7,7 +7,7 @@ import React from 'react'; import ApplySubmitOnboardingIntent from './ApplySubmitOnboardingIntent'; /** - * Recognises the `intent=submit` onboarding deeplink and hands off to the component that acts on it. + * Recognizes the `intent=submit` onboarding deeplink and hands off to the component that acts on it. */ function SubmitIntentDeeplinkHandler() { const onboardingDeeplinkIntent = useOnboardingDeeplinkIntent(); diff --git a/src/libs/getOnboardingIntentFromUrl.ts b/src/libs/getOnboardingIntentFromUrl.ts index fc54ba5d2c73..46693ee0ac41 100644 --- a/src/libs/getOnboardingIntentFromUrl.ts +++ b/src/libs/getOnboardingIntentFromUrl.ts @@ -4,8 +4,8 @@ * * The param arrives in one of two shapes: * - directly, when the recipient is already signed in: `/onboarding?intent=submit` - * - nested in the `exitTo` of an auth handoff link: `/transition?...&exitTo=onboarding%3Fintent%3Dsubmit` or - * `/v//?exitTo=onboarding%3Fintent%3Dsubmit` + * - nested in the `exitTo` of an auth handoff link, where `onboarding?intent=submit` is URL-encoded: + * `/transition?...&exitTo=` or `/v//?exitTo=` * * Nesting it in `exitTo` is what carries the intent across the logged-out -> logged-in transition: the deeplink * outlives the sign-in itself, so the intent is still readable once the authenticated screens mount. From 3215c16b871dc3115f3ebfa5439b9067a970b134 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Tue, 18 Aug 2026 22:29:57 +0200 Subject: [PATCH 06/13] Fix native deeplink paths for the Submit onboarding intent Custom-scheme links like new-expensify://onboarding?intent=submit put the route where a host would sit, so stripping the origin discarded the route and the intent was lost. Links opened while the app was already running were missed too, since the latched URL is stale by then and only secure links were recorded. --- src/DeepLinkHandler.tsx | 10 ++++++---- src/hooks/useOnboardingDeeplinkIntent.ts | 3 ++- src/libs/getOnboardingIntentFromUrl.ts | 13 ++++++++++--- tests/unit/getOnboardingIntentFromUrlTest.ts | 16 ++++++++++++++++ 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index b581317c3e81..7442129d8f15 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -11,6 +11,7 @@ import useOnyx from './hooks/useOnyx'; import {openReportFromDeepLink} from './libs/actions/Link'; import * as Report from './libs/actions/Report'; import {hasAuthToken, isAnonymousUser} from './libs/actions/Session'; +import getOnboardingIntentFromUrl from './libs/getOnboardingIntentFromUrl'; import Log from './libs/Log'; import {getReportIDFromLink} from './libs/ReportUtils'; import {endSpan} from './libs/telemetry/activeSpans'; @@ -141,10 +142,11 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url}); } const isCurrentlyAuthenticated = hasAuthToken(); - // A Submit-via-PDF secure access link can arrive while the app is already running (warm), where - // getInitialURL() is empty. Record it so onboarding suppression has a session-sticky signal, the same - // way the cold path does via onInitialUrl above. Scoped to secure links so other deep links are unaffected. - if (hasSecureLinkKey(state.url)) { + // A Submit-via-PDF secure access link, or an onboarding deeplink carrying an intent, can arrive while the + // app is already running (warm), where getInitialURL() is empty. Record it so the handlers reading the + // initial URL have a session-sticky signal, the same way the cold path does via onInitialUrl above. + // Scoped to those two link shapes so other deep links are unaffected. + if (hasSecureLinkKey(state.url) || getOnboardingIntentFromUrl(state.url)) { onInitialUrl(state.url as Route); } openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas, session?.accountID ?? CONST.DEFAULT_NUMBER_ID); diff --git a/src/hooks/useOnboardingDeeplinkIntent.ts b/src/hooks/useOnboardingDeeplinkIntent.ts index 90ee458f5ec0..07f2b168d036 100644 --- a/src/hooks/useOnboardingDeeplinkIntent.ts +++ b/src/hooks/useOnboardingDeeplinkIntent.ts @@ -12,7 +12,8 @@ import {useState} from 'react'; * * The URL is latched at mount because the app rewrites it as soon as it navigates, which happens well before the * intent has been acted on. The initial URL is the only source on native, where the browser URL is empty and the - * deeplink resolves asynchronously. + * deeplink resolves asynchronously. It also covers links opened while the app is already running, which DeepLinkHandler + * records there because the latched URL is stale by then. */ function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined { const {initialURL} = useInitialURLState(); diff --git a/src/libs/getOnboardingIntentFromUrl.ts b/src/libs/getOnboardingIntentFromUrl.ts index 46693ee0ac41..8de9e7b20835 100644 --- a/src/libs/getOnboardingIntentFromUrl.ts +++ b/src/libs/getOnboardingIntentFromUrl.ts @@ -22,10 +22,17 @@ function isOnboardingIntent(value: string | null): value is OnboardingIntent { return !!value && ONBOARDING_INTENT_VALUES.has(value); } -/** Strips the scheme and host so absolute URLs and in-app paths can be inspected the same way. */ +/** + * Strips the scheme and, for web URLs, the host, so absolute URLs and in-app paths can be inspected the same way. + * + * Custom schemes have to keep the segment straight after `://`. In `new-expensify://onboarding?intent=submit` that + * segment is the route rather than a host, so dropping it the way we drop `new.expensify.com` would discard the + * route and lose the intent. The `app://-/` prefix puts a placeholder host there instead, which is dropped. + */ function getPathWithQuery(url: string): string { - const [withoutHash] = url.replace(/^[a-z][\w+.-]*:\/\/[^/]*/i, '').split('#', 2); - return withoutHash.replace(/^\/+/, ''); + const [withoutHash] = url.split('#', 2); + const withoutOrigin = /^https?:\/\//i.test(withoutHash) ? withoutHash.replace(/^https?:\/\/[^/?#]*/i, '') : withoutHash.replace(/^[a-z][\w+.-]*:\/\//i, ''); + return withoutOrigin.replace(/^(-\/)?\/*/, ''); } function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined { diff --git a/tests/unit/getOnboardingIntentFromUrlTest.ts b/tests/unit/getOnboardingIntentFromUrlTest.ts index 60f8ee6877b3..71b911a586fa 100644 --- a/tests/unit/getOnboardingIntentFromUrlTest.ts +++ b/tests/unit/getOnboardingIntentFromUrlTest.ts @@ -7,6 +7,22 @@ describe('getOnboardingIntentFromUrl', () => { expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); }); + it('reads the intent from a custom scheme link, where the route sits where a host would', () => { + expect(getOnboardingIntentFromUrl('new-expensify://onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from a custom scheme link with a placeholder host', () => { + expect(getOnboardingIntentFromUrl('app://-/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from the exitTo of a custom scheme magic link', () => { + expect(getOnboardingIntentFromUrl('new-expensify://v/12345/678910?exitTo=onboarding%3Fintent%3Dsubmit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + + it('reads the intent from a link served on a port, as the dev server does', () => { + expect(getOnboardingIntentFromUrl('https://dev.new.expensify.com:8082/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + it('reads the intent from an in-app path without an origin', () => { expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); }); From 7fad41a199c0153f6702cfe27fdc23a0a425e1fe Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 20 Aug 2026 07:42:44 +0200 Subject: [PATCH 07/13] Parse the deeplink with getRouteFromLink instead of a local helper getRouteFromLink already strips whichever linking-config prefix matched, which covers the app schemes and the dev server's port, and it is what DeepLinkHandler uses to read report deeplinks. --- src/libs/getOnboardingIntentFromUrl.ts | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/libs/getOnboardingIntentFromUrl.ts b/src/libs/getOnboardingIntentFromUrl.ts index 8de9e7b20835..f3e50bbb907b 100644 --- a/src/libs/getOnboardingIntentFromUrl.ts +++ b/src/libs/getOnboardingIntentFromUrl.ts @@ -14,6 +14,7 @@ import type {OnboardingIntent} from '@src/CONST'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; +import {getRouteFromLink} from './ReportUtils'; import {getSearchParamFromPath} from './Url'; const ONBOARDING_INTENT_VALUES = new Set(Object.values(CONST.ONBOARDING_INTENTS)); @@ -22,25 +23,15 @@ function isOnboardingIntent(value: string | null): value is OnboardingIntent { return !!value && ONBOARDING_INTENT_VALUES.has(value); } -/** - * Strips the scheme and, for web URLs, the host, so absolute URLs and in-app paths can be inspected the same way. - * - * Custom schemes have to keep the segment straight after `://`. In `new-expensify://onboarding?intent=submit` that - * segment is the route rather than a host, so dropping it the way we drop `new.expensify.com` would discard the - * route and lose the intent. The `app://-/` prefix puts a placeholder host there instead, which is dropped. - */ -function getPathWithQuery(url: string): string { - const [withoutHash] = url.split('#', 2); - const withoutOrigin = /^https?:\/\//i.test(withoutHash) ? withoutHash.replace(/^https?:\/\/[^/?#]*/i, '') : withoutHash.replace(/^[a-z][\w+.-]*:\/\//i, ''); - return withoutOrigin.replace(/^(-\/)?\/*/, ''); -} - function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingIntent | undefined { if (!url) { return undefined; } - const pathWithQuery = getPathWithQuery(url); + // getRouteFromLink strips whichever linking-config prefix matched, so every shape the deeplink can arrive in + // reduces to the same route: web URLs, the dev server's port, the desktop `app://-/` origin and the native + // `new-expensify://` scheme. It only drops the leading slash when a prefix matched, so in-app paths keep theirs. + const pathWithQuery = getRouteFromLink(url).replace(/^\/+/, ''); const onboardingPathWithQuery = pathWithQuery.startsWith(ROUTES.ONBOARDING_ROOT.route) ? pathWithQuery : getSearchParamFromPath(pathWithQuery, 'exitTo'); if (!onboardingPathWithQuery?.startsWith(ROUTES.ONBOARDING_ROOT.route)) { From 8bb00544032f7174f8f1f875789c4ea32cee635c Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 20 Aug 2026 07:46:14 +0200 Subject: [PATCH 08/13] Trim comments down to the parts that aren't evident from the code --- src/CONST/index.ts | 5 ++-- src/DeepLinkHandler.tsx | 1 - src/ROUTES.ts | 4 ---- src/hooks/useOnboardingDeeplinkIntent.ts | 6 ++--- .../ApplySubmitOnboardingIntent.tsx | 24 ++++++------------- src/libs/getOnboardingIntentFromUrl.ts | 17 ++++--------- 6 files changed, 16 insertions(+), 41 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c481e31dd9cf..e6813074be25 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -143,9 +143,8 @@ const createExpenseOnboardingChoices = { SUBMIT: backendOnboardingChoices.SUBMIT, } as const; -// Values accepted by the `intent` param on the onboarding deeplink (e.g. `onboarding?intent=submit`). These are -// short, stable, marketing-friendly aliases rather than the internal onboarding choice strings, because they are -// embedded in emails and other links we can't redeploy. +// Values accepted by the `intent` param on the onboarding deeplink. Kept separate from the internal onboarding +// choice strings because they are embedded in emails we can't redeploy. const onboardingIntents = { SUBMIT: 'submit', } as const; diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 7442129d8f15..fce1256b040d 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -145,7 +145,6 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { // A Submit-via-PDF secure access link, or an onboarding deeplink carrying an intent, can arrive while the // app is already running (warm), where getInitialURL() is empty. Record it so the handlers reading the // initial URL have a session-sticky signal, the same way the cold path does via onInitialUrl above. - // Scoped to those two link shapes so other deep links are unaffected. if (hasSecureLinkKey(state.url) || getOnboardingIntentFromUrl(state.url)) { onInitialUrl(state.url as Route); } diff --git a/src/ROUTES.ts b/src/ROUTES.ts index cb2355995d0f..f55c72f7e230 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3939,10 +3939,6 @@ const ROUTES = { ONBOARDING_ROOT: { route: 'onboarding', - /** - * @param intent - Pre-selects an onboarding outcome so a one-click link can land the user on the - * matching workspace instead of making them pick the intent in the UI. - */ getRoute: (intent?: OnboardingIntent) => (intent ? (`onboarding?intent=${intent}` as const) : ('onboarding' as const)), }, ONBOARDING_PERSONAL_DETAILS: { diff --git a/src/hooks/useOnboardingDeeplinkIntent.ts b/src/hooks/useOnboardingDeeplinkIntent.ts index 07f2b168d036..893fd5e467ba 100644 --- a/src/hooks/useOnboardingDeeplinkIntent.ts +++ b/src/hooks/useOnboardingDeeplinkIntent.ts @@ -10,10 +10,8 @@ import {useState} from 'react'; /** * Resolves the onboarding outcome requested by the deeplink this session was opened with, e.g. `onboarding?intent=submit`. * - * The URL is latched at mount because the app rewrites it as soon as it navigates, which happens well before the - * intent has been acted on. The initial URL is the only source on native, where the browser URL is empty and the - * deeplink resolves asynchronously. It also covers links opened while the app is already running, which DeepLinkHandler - * records there because the latched URL is stale by then. + * The URL is latched at mount because the app rewrites it as soon as it navigates. The initial URL covers native, + * where the browser URL is empty, and links opened while the app is already running. */ function useOnboardingDeeplinkIntent(): OnboardingIntent | undefined { const {initialURL} = useInitialURLState(); diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx index 4d1dc1887d41..78d17e148bcf 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -13,21 +13,15 @@ import {useEffect, useRef} from 'react'; /** * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. * - * The link is only sent to existing users, so it acts solely on recipients who have already been through guided - * setup: for them the workspace is created outright and they land wherever the "Submit to my employer" flow normally - * leaves them. Anyone who still has onboarding ahead of them is left to it untouched, since that flow already offers - * the Submit outcome. - * - * Only rendered once the deeplink has been recognized, so the Onyx subscriptions behind `useAutoCreateSubmitWorkspace` - * are never set up for ordinary sessions. + * The link only goes to existing users, so it acts solely on recipients who have finished guided setup. Anyone who + * still has onboarding ahead of them is left to it, since that flow already offers the Submit outcome. */ function ApplySubmitOnboardingIntent() { const {firstName, lastName} = useCurrentUserPersonalDetails(); const autoCreateSubmitWorkspace = useAutoCreateSubmitWorkspace(); - // HAS_LOADED_APP only flips true once this session's account data has landed, so waiting on it keeps the - // eligibility checks inside useAutoCreateSubmitWorkspace (existing workspaces, restricted policy creation) - // from running against a half-populated store and creating a duplicate workspace. + // Waiting on HAS_LOADED_APP keeps the eligibility checks inside useAutoCreateSubmitWorkspace from running + // against a half-populated store, where they would miss an existing workspace and create a duplicate. const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP); const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); @@ -40,19 +34,15 @@ function ApplySubmitOnboardingIntent() { } hasRun.current = true; - // Recipients who never finished guided setup are left to the normal onboarding flow, which already offers - // the Submit outcome. if (!isOnboardingCompleted) { return; } - // The deeplink delivers the same outcome as the Submit plan welcome modal, so record the modal as seen to - // stop it from opening on top of the workspace we're about to create. + // The deeplink delivers the same outcome as the Submit plan welcome modal, so keep that modal from opening too. setSubmitMigrationModalShown(); - // Guided setup is already done, so it must not run again. When the user already owns a Submit workspace, - // useAutoCreateSubmitWorkspace skips creation and navigates to that workspace instead, which is what makes - // repeat clicks of the link idempotent. + // `false` skips CompleteGuidedSetup, which is already done. The hook navigates to the user's existing Submit + // workspace rather than creating a second one, which is what makes repeat clicks idempotent. autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); }, [autoCreateSubmitWorkspace, firstName, hasLoadedApp, isOnboardingCompleted, isSupportalSession, lastName]); diff --git a/src/libs/getOnboardingIntentFromUrl.ts b/src/libs/getOnboardingIntentFromUrl.ts index f3e50bbb907b..4f91414712d3 100644 --- a/src/libs/getOnboardingIntentFromUrl.ts +++ b/src/libs/getOnboardingIntentFromUrl.ts @@ -1,14 +1,8 @@ /** - * Reads the `intent` param of the onboarding deeplink (e.g. `onboarding?intent=submit`), which lets a one-click - * link pre-select an onboarding outcome instead of asking the recipient to pick it in the UI. + * Reads the `intent` param of the onboarding deeplink (e.g. `onboarding?intent=submit`). * - * The param arrives in one of two shapes: - * - directly, when the recipient is already signed in: `/onboarding?intent=submit` - * - nested in the `exitTo` of an auth handoff link, where `onboarding?intent=submit` is URL-encoded: - * `/transition?...&exitTo=` or `/v//?exitTo=` - * - * Nesting it in `exitTo` is what carries the intent across the logged-out -> logged-in transition: the deeplink - * outlives the sign-in itself, so the intent is still readable once the authenticated screens mount. + * It arrives either directly, or nested in the `exitTo` of a transition or magic link. Nesting it in `exitTo` is what + * carries the intent across the logged-out to logged-in transition, since the deeplink outlives the sign-in itself. */ import type {OnboardingIntent} from '@src/CONST'; import CONST from '@src/CONST'; @@ -28,9 +22,8 @@ function getOnboardingIntentFromUrl(url: string | null | undefined): OnboardingI return undefined; } - // getRouteFromLink strips whichever linking-config prefix matched, so every shape the deeplink can arrive in - // reduces to the same route: web URLs, the dev server's port, the desktop `app://-/` origin and the native - // `new-expensify://` scheme. It only drops the leading slash when a prefix matched, so in-app paths keep theirs. + // getRouteFromLink strips whichever linking-config prefix matched, so web URLs, the desktop `app://-/` origin and + // the native scheme all reduce to the same route. It leaves the leading slash on in-app paths. const pathWithQuery = getRouteFromLink(url).replace(/^\/+/, ''); const onboardingPathWithQuery = pathWithQuery.startsWith(ROUTES.ONBOARDING_ROOT.route) ? pathWithQuery : getSearchParamFromPath(pathWithQuery, 'exitTo'); From 22db33d866211e6930be7d896b5245d4e8a0b3f0 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 20 Aug 2026 07:51:24 +0200 Subject: [PATCH 09/13] Build the encoded exitTo in tests instead of hardcoding the escapes cspell reads the percent-encoded literals as the unknown words "Fintent" and "Dsubmit", and encoding explicitly shows what the link actually carries. --- tests/unit/getOnboardingIntentFromUrlTest.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/unit/getOnboardingIntentFromUrlTest.ts b/tests/unit/getOnboardingIntentFromUrlTest.ts index 71b911a586fa..c69fd0dd7443 100644 --- a/tests/unit/getOnboardingIntentFromUrlTest.ts +++ b/tests/unit/getOnboardingIntentFromUrlTest.ts @@ -3,6 +3,8 @@ import getOnboardingIntentFromUrl from '@libs/getOnboardingIntentFromUrl'; import CONST from '@src/CONST'; describe('getOnboardingIntentFromUrl', () => { + const encodedOnboardingRoute = encodeURIComponent('onboarding?intent=submit'); + it('reads the intent from a direct onboarding link', () => { expect(getOnboardingIntentFromUrl('https://new.expensify.com/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); }); @@ -16,7 +18,7 @@ describe('getOnboardingIntentFromUrl', () => { }); it('reads the intent from the exitTo of a custom scheme magic link', () => { - expect(getOnboardingIntentFromUrl('new-expensify://v/12345/678910?exitTo=onboarding%3Fintent%3Dsubmit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + expect(getOnboardingIntentFromUrl(`new-expensify://v/12345/678910?exitTo=${encodedOnboardingRoute}`)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); }); it('reads the intent from a link served on a port, as the dev server does', () => { @@ -28,13 +30,13 @@ describe('getOnboardingIntentFromUrl', () => { }); it('reads the intent from the exitTo of an OldDot transition link', () => { - const url = 'https://new.expensify.com/transition?email=me%40example.com&shortLivedAuthToken=abc123&exitTo=onboarding%3Fintent%3Dsubmit'; + const url = `https://new.expensify.com/transition?email=${encodeURIComponent('me@example.com')}&shortLivedAuthToken=abc123&exitTo=${encodedOnboardingRoute}`; expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); }); it('reads the intent from the exitTo of a magic link', () => { - const url = 'https://new.expensify.com/v/12345/678910?exitTo=onboarding%3Fintent%3Dsubmit'; + const url = `https://new.expensify.com/v/12345/678910?exitTo=${encodedOnboardingRoute}`; expect(getOnboardingIntentFromUrl(url)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); }); @@ -51,7 +53,7 @@ describe('getOnboardingIntentFromUrl', () => { ['an onboarding link without an intent', 'https://new.expensify.com/onboarding'], ['an unknown intent value', 'https://new.expensify.com/onboarding?intent=notARealIntent'], ['an intent on a non-onboarding route', 'https://new.expensify.com/settings/profile?intent=submit'], - ['an intent on a non-onboarding exitTo', 'https://new.expensify.com/transition?exitTo=workspace%2Fnew%3Fintent%3Dsubmit'], + ['an intent on a non-onboarding exitTo', `https://new.expensify.com/transition?exitTo=${encodeURIComponent('workspace/new?intent=submit')}`], ])('returns undefined for %s', (_description, url) => { expect(getOnboardingIntentFromUrl(url)).toBeUndefined(); }); From f1887b48a9ae692c16c8ba33d1dd1a9a6475669c Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 20 Aug 2026 08:08:06 +0200 Subject: [PATCH 10/13] Apply the Submit intent at most once per app process The initial URL outlives a sign-out, so remounting on a second account read the same intent again and created a Submit workspace for that account. --- .../ApplySubmitOnboardingIntent.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx index 78d17e148bcf..3b63312456fa 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -8,7 +8,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; import {isSupportalSessionSelector} from '@selectors/Session'; -import {useEffect, useRef} from 'react'; +import {useEffect} from 'react'; + +// Module scope rather than a ref so it survives this component remounting. The deeplink is read from the initial URL, +// which the provider above the navigator keeps for the life of the process, so signing out and into another account +// remounts this component with the same intent still readable and would create a workspace for that second account. +let hasAppliedIntent = false; /** * Creates the Submit workspace requested by an `intent=submit` onboarding deeplink. @@ -26,13 +31,11 @@ function ApplySubmitOnboardingIntent() { const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); - const hasRun = useRef(false); - useEffect(() => { - if (hasRun.current || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { + if (hasAppliedIntent || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { return; } - hasRun.current = true; + hasAppliedIntent = true; if (!isOnboardingCompleted) { return; From fca471cfefea0b90c8127baecfc16a8bf90b1451 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Thu, 20 Aug 2026 08:52:02 +0200 Subject: [PATCH 11/13] Drop the unrelated comment fix in SubmitPlanWelcomeModal Keeps the diff to files this feature actually changes. --- src/components/SubmitPlanWelcomeModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SubmitPlanWelcomeModal.tsx b/src/components/SubmitPlanWelcomeModal.tsx index 4e9394cac5cb..2a9c62237503 100644 --- a/src/components/SubmitPlanWelcomeModal.tsx +++ b/src/components/SubmitPlanWelcomeModal.tsx @@ -49,8 +49,8 @@ function SubmitPlanWelcomeModal() { const handleConfirm = () => { // The user has already completed onboarding, so we skip CompleteGuidedSetup and just create the - // Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Spend - // with #admins in the side panel, which triggers the useBeforeRemove persistence above. + // Submit workspace. autoCreateSubmitWorkspace then dismisses this modal and navigates to Categories + // with #admins in the RHP, which triggers the useBeforeRemove persistence above. autoCreateSubmitWorkspace(firstName ?? '', lastName ?? '', false); }; From a6e5615680b9c5728831f20368a809ee3dd893c9 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sun, 23 Aug 2026 15:28:17 +0200 Subject: [PATCH 12/13] Cover the bare-path magic link HybridApp passes to NewDot This is the shape the emailed link actually arrives in on mobile, where OldDot hands over a path rather than a full URL. --- tests/unit/getOnboardingIntentFromUrlTest.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/unit/getOnboardingIntentFromUrlTest.ts b/tests/unit/getOnboardingIntentFromUrlTest.ts index c69fd0dd7443..213a70c0137d 100644 --- a/tests/unit/getOnboardingIntentFromUrlTest.ts +++ b/tests/unit/getOnboardingIntentFromUrlTest.ts @@ -29,6 +29,12 @@ describe('getOnboardingIntentFromUrl', () => { expect(getOnboardingIntentFromUrl('/onboarding?intent=submit')).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); }); + // HybridApp hands NewDot the deeplink as a bare path rather than a full URL, which is the shape the emailed + // magic link arrives in on mobile. + it('reads the intent from the exitTo of a magic link passed as a bare path', () => { + expect(getOnboardingIntentFromUrl(`v/12345/678910?exitTo=${encodedOnboardingRoute}`)).toBe(CONST.ONBOARDING_INTENTS.SUBMIT); + }); + it('reads the intent from the exitTo of an OldDot transition link', () => { const url = `https://new.expensify.com/transition?email=${encodeURIComponent('me@example.com')}&shortLivedAuthToken=abc123&exitTo=${encodedOnboardingRoute}`; From 418fd88940d38881bcff40fefc27fce0d7239103 Mon Sep 17 00:00:00 2001 From: Abdelrahman Khattab Date: Sun, 23 Aug 2026 22:16:59 +0200 Subject: [PATCH 13/13] Drop the intent on supportal and pre-empt the welcome modal race Supportal was grouped with the not-ready-yet conditions, so it returned without marking the intent applied. Since the initial URL is sticky for the life of the process, the intent stayed live and the next sign-in would act on it. It is a reason to drop the intent, not to wait, so it now sits with the other drop condition. SubmitPlanWelcomeModalGuard schedules its proactive navigation on a microtask off HAS_LOADED_APP, which always beats a React effect, so marking the modal shown from the deeplink could not stop it opening on top of the flow. The deeplink now tells the guard directly at mount. --- .../ApplySubmitOnboardingIntent.tsx | 16 ++++++++++++--- .../guards/SubmitPlanWelcomeModalGuard.ts | 15 ++++++++++++-- .../SubmitPlanWelcomeModalGuard.test.ts | 20 ++++++++++++++++++- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx index 3b63312456fa..2464d891e544 100644 --- a/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx +++ b/src/libs/Navigation/AppNavigator/SubmitIntentDeeplinkHandler/ApplySubmitOnboardingIntent.tsx @@ -2,6 +2,8 @@ import useAutoCreateSubmitWorkspace from '@hooks/useAutoCreateSubmitWorkspace'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useOnyx from '@hooks/useOnyx'; +import {suppressWelcomeModalForSubmitDeeplink} from '@libs/Navigation/guards/SubmitPlanWelcomeModalGuard'; + import {setSubmitMigrationModalShown} from '@userActions/User'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -31,17 +33,25 @@ function ApplySubmitOnboardingIntent() { const [isOnboardingCompleted] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); const [isSupportalSession] = useOnyx(ONYXKEYS.SESSION, {selector: isSupportalSessionSelector}); + // Runs at mount rather than alongside the work below, which waits on HAS_LOADED_APP — the same signal the guard + // uses to decide whether to open its modal. + useEffect(() => { + suppressWelcomeModalForSubmitDeeplink(); + }, []); + useEffect(() => { - if (hasAppliedIntent || !hasLoadedApp || isOnboardingCompleted === undefined || isSupportalSession) { + if (hasAppliedIntent || !hasLoadedApp || isOnboardingCompleted === undefined) { return; } hasAppliedIntent = true; - if (!isOnboardingCompleted) { + // Marked applied above before these checks, since they are reasons to drop the intent rather than to wait for + // it to become actionable. Returning without consuming would leave it live for whoever signs in next. + if (!isOnboardingCompleted || isSupportalSession) { return; } - // The deeplink delivers the same outcome as the Submit plan welcome modal, so keep that modal from opening too. + // Persists the suppression applied above, so the modal stays away in later sessions too. setSubmitMigrationModalShown(); // `false` skips CompleteGuidedSetup, which is already done. The hook navigates to the user's existing Submit diff --git a/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts b/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts index 7a6cdde709a5..81f2138ff01d 100644 --- a/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts +++ b/src/libs/Navigation/guards/SubmitPlanWelcomeModalGuard.ts @@ -38,6 +38,15 @@ let hasLoadedApp = false; let hasRedirectedToSubmitPlanModal = false; let isEvaluationScheduled = false; +// An `intent=submit` deeplink delivers the same outcome as this modal without asking, so the modal must not open on +// top of it. Recorded outside Onyx because the deeplink can only mark the modal shown from a React effect, which runs +// after the microtask that schedules the proactive redirect below. +let hasPendingSubmitDeeplink = false; + +function suppressWelcomeModalForSubmitDeeplink() { + hasPendingSubmitDeeplink = true; +} + const SUBMIT_PLAN_WELCOME_ENTRY_SCREENS = new Set(DYNAMIC_ROUTES.SUBMIT_PLAN_WELCOME.entryScreens); /** @@ -73,6 +82,7 @@ function getSubmitPlanWelcomeModalRoute(basePath?: string): Route { function resetSessionFlag() { hasRedirectedToSubmitPlanModal = false; + hasPendingSubmitDeeplink = false; } /** @@ -114,6 +124,7 @@ function isPolicyCreationRestricted(): boolean { */ function navigateToSubmitPlanWelcomeModalIfReady() { if ( + hasPendingSubmitDeeplink || isSupportalSessionSelector(session) || !session?.authToken || isLoadingApp || @@ -285,7 +296,7 @@ const SubmitPlanWelcomeModalGuard: NavigationGuard = { return {type: 'ALLOW'}; } - if (context.isSupportalSession || !shouldShowSubmitPlanWelcomeModal()) { + if (hasPendingSubmitDeeplink || context.isSupportalSession || !shouldShowSubmitPlanWelcomeModal()) { return {type: 'ALLOW'}; } @@ -299,4 +310,4 @@ const SubmitPlanWelcomeModalGuard: NavigationGuard = { }; export default SubmitPlanWelcomeModalGuard; -export {resetSessionFlag}; +export {resetSessionFlag, suppressWelcomeModalForSubmitDeeplink}; diff --git a/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts b/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts index 435201e2483a..15ccc03e97df 100644 --- a/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts +++ b/tests/unit/Navigation/guards/SubmitPlanWelcomeModalGuard.test.ts @@ -1,4 +1,4 @@ -import SubmitPlanWelcomeModalGuard, {resetSessionFlag} from '@libs/Navigation/guards/SubmitPlanWelcomeModalGuard'; +import SubmitPlanWelcomeModalGuard, {resetSessionFlag, suppressWelcomeModalForSubmitDeeplink} from '@libs/Navigation/guards/SubmitPlanWelcomeModalGuard'; import type {GuardContext} from '@libs/Navigation/guards/types'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; // eslint-disable-next-line no-restricted-imports -- type-only namespace import used solely to type jest.requireActual for the module mock below @@ -160,6 +160,14 @@ describe('SubmitPlanWelcomeModalGuard', () => { expect(secondResult.type).toBe('ALLOW'); }); + it('should allow when an intent=submit deeplink is creating the workspace already', async () => { + await setUpEligibleUser(); + suppressWelcomeModalForSubmitDeeplink(); + + const result = SubmitPlanWelcomeModalGuard.evaluate(mockState, mockAction, defaultContext); + expect(result.type).toBe('ALLOW'); + }); + it('should allow when already on the submit plan welcome modal screen', async () => { await setUpEligibleUser(); @@ -192,6 +200,16 @@ describe('SubmitPlanWelcomeModalGuard', () => { expect(mockNavigate).toHaveBeenCalledWith(submitPlanWelcomeRoute); }); + it('should not navigate when an intent=submit deeplink is creating the workspace already', async () => { + await setUpEligibleUser(); + suppressWelcomeModalForSubmitDeeplink(); + mockNavigate.mockClear(); + + await markSessionReady({authToken: 'test-token', accountID: 123}); + + expect(mockNavigate).not.toHaveBeenCalled(); + }); + it('should not navigate when there is no session', async () => { await setUpEligibleUser(); mockNavigate.mockClear();