From c307c8457eaacc9c31d9a706e4b17f2118ea8c84 Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 17 Jul 2026 16:06:13 +0430 Subject: [PATCH 01/33] fix: navigate to Concierge chat after login from deep link on web --- src/libs/actions/Link.ts | 15 +++++++++++ src/libs/actions/SignInRedirect.ts | 3 +++ src/libs/navigateAfterOnboarding.ts | 3 +++ tests/unit/navigateAfterOnboardingTest.ts | 33 +++++++++++++++++++++++ 4 files changed, 54 insertions(+) diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index a995f6d2ce24..e39bd54ade0a 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -16,6 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import {getIsOffline} from '@libs/NetworkState'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import {findLastAccessedReport, getReportIDFromLink, getReportOrDraftReport, getRouteFromLink, isMoneyRequestReport} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -451,6 +452,12 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { openExternalLink(href); } +function isConciergeRoute(route: string) { + const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); + const normalizedRoute = routeWithoutParams.replace(/\/$/, ''); + return normalizedRoute === normalizePath(ROUTES.CONCIERGE); +} + function openReportFromDeepLink( url: string, reports: OnyxCollection, @@ -496,6 +503,14 @@ function openReportFromDeepLink( route = ''; } + if (!isAuthenticated) { + if (isConciergeRoute(route)) { + setPendingConciergeDeepLink(); + } else { + clearPendingConciergeDeepLink(); + } + } + // If we are not authenticated and are navigating to a public screen, we don't want to navigate again to the screen after sign-in/sign-up if (!isAuthenticated && isPublicScreenRoute(route)) { return; diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 874c2a083355..2ec1643244bc 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -1,6 +1,7 @@ import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getIsOffline} from '@libs/NetworkState'; +import {clearPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import CONFIG from '@src/CONFIG'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -47,6 +48,8 @@ Onyx.connectWithoutView({ }); function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: boolean): Promise { + clearPendingConciergeDeepLink(); + // Under certain conditions, there are key-values we'd like to keep in storage even when a user is logged out. // We pass these into the clear() method in order to avoid having to reset them on a delayed tick and getting // flashes of unwanted default state. diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index fd13b357a6b7..d40f27c785ed 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -15,6 +15,7 @@ import SidePanelActions from './actions/SidePanel'; import {setOnboardingRHPVariant} from './actions/Welcome'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; +import {consumePendingConciergeDeepLink} from './PendingConciergeDeepLink'; import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; let onboardingRHPVariant: OnyxEntry; @@ -99,6 +100,8 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } else if (consumePendingConciergeDeepLink()) { + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); } else { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 634558a7f2d8..e3b83d7e3ee1 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,5 +1,6 @@ import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import type * as ReportUtils from '@libs/ReportUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -62,6 +63,7 @@ describe('navigateAfterOnboarding', () => { beforeEach(async () => { jest.clearAllMocks(); + clearPendingConciergeDeepLink(); return Onyx.clear(); }); @@ -155,4 +157,35 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); }); + + it('should navigate to Concierge instead of Home when a pending Concierge deep link is available', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + }); + + it('should navigate to Concierge route when pending deep link is set but conciergeReportID is empty', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, '', {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.CONCIERGE); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + }); + + it('should consume the pending Concierge deep link after onboarding navigation', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenNthCalledWith(1, ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME); + }); }); From f870138760de6449cadce28f64378de0fae58a06 Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 17 Jul 2026 16:54:53 +0430 Subject: [PATCH 02/33] Fix missing pending Concierge deep link helper --- src/libs/PendingConciergeDeepLink.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/libs/PendingConciergeDeepLink.ts diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts new file mode 100644 index 000000000000..94e7592c49d4 --- /dev/null +++ b/src/libs/PendingConciergeDeepLink.ts @@ -0,0 +1,17 @@ +let hasPendingConciergeDeepLink = false; + +function setPendingConciergeDeepLink() { + hasPendingConciergeDeepLink = true; +} + +function consumePendingConciergeDeepLink() { + const shouldNavigateToConcierge = hasPendingConciergeDeepLink; + hasPendingConciergeDeepLink = false; + return shouldNavigateToConcierge; +} + +function clearPendingConciergeDeepLink() { + hasPendingConciergeDeepLink = false; +} + +export {setPendingConciergeDeepLink, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink}; From b9720821f5ad0bae09fce01e388c0cef884eb7b8 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 18 Jul 2026 00:43:36 +0430 Subject: [PATCH 03/33] Add comment for pending Concierge intent helper --- src/libs/PendingConciergeDeepLink.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 94e7592c49d4..941da724f54e 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -1,3 +1,7 @@ +/** + * Tracks whether a logged-out user opened a /concierge deep link so the app can + * route them to Concierge after sign-up/onboarding, then clear the intent on sign-out. + */ let hasPendingConciergeDeepLink = false; function setPendingConciergeDeepLink() { From e6df44918db978252d89ba290df8f303a9e649a3 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sun, 19 Jul 2026 00:26:28 +0430 Subject: [PATCH 04/33] fix: persist pending Concierge deep link across reloads --- src/libs/PendingConciergeDeepLink.ts | 34 +++++++++++++++++++++-- src/libs/actions/Link.ts | 2 +- tests/unit/navigateAfterOnboardingTest.ts | 13 +++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 941da724f54e..52c611804c86 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -1,21 +1,49 @@ /** * Tracks whether a logged-out user opened a /concierge deep link so the app can - * route them to Concierge after sign-up/onboarding, then clear the intent on sign-out. + * route them to Concierge after sign-up/onboarding. sessionStorage keeps the + * tab-scoped intent across page reloads while sign-out/consume paths clear it. */ +const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; let hasPendingConciergeDeepLink = false; +function getSessionStorage() { + try { + return typeof window === 'undefined' ? undefined : window.sessionStorage; + } catch { + return undefined; + } +} + +function hasStoredPendingConciergeDeepLink() { + try { + return getSessionStorage()?.getItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + function setPendingConciergeDeepLink() { hasPendingConciergeDeepLink = true; + try { + getSessionStorage()?.setItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY, 'true'); + } catch { + // Ignore storage failures and keep the in-memory intent for the current page lifecycle. + } } function consumePendingConciergeDeepLink() { - const shouldNavigateToConcierge = hasPendingConciergeDeepLink; - hasPendingConciergeDeepLink = false; + const shouldNavigateToConcierge = hasPendingConciergeDeepLink || hasStoredPendingConciergeDeepLink(); + clearPendingConciergeDeepLink(); return shouldNavigateToConcierge; } function clearPendingConciergeDeepLink() { hasPendingConciergeDeepLink = false; + try { + getSessionStorage()?.removeItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); + } catch { + // Ignore storage failures since clearing the in-memory flag is still enough for this page lifecycle. + } } export {setPendingConciergeDeepLink, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index e39bd54ade0a..cb86eb613d09 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -506,7 +506,7 @@ function openReportFromDeepLink( if (!isAuthenticated) { if (isConciergeRoute(route)) { setPendingConciergeDeepLink(); - } else { + } else if (!isPublicScreenRoute(route)) { clearPendingConciergeDeepLink(); } } diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 444e616cd99a..910917f0d93d 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -224,4 +224,17 @@ describe('navigateAfterOnboarding', () => { expect(navigate).toHaveBeenNthCalledWith(1, ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME); }); + + it('should preserve the pending Concierge deep link across a module reload', () => { + setPendingConciergeDeepLink(); + + jest.isolateModules(() => { + const {consumePendingConciergeDeepLink: consumePendingConciergeDeepLinkAfterReload} = + jest.requireActual('@libs/PendingConciergeDeepLink'); + expect(consumePendingConciergeDeepLinkAfterReload()).toBe(true); + }); + + expect(window.sessionStorage.getItem('PENDING_CONCIERGE_DEEP_LINK')).toBeNull(); + clearPendingConciergeDeepLink(); + }); }); From 91e65ec2775ff46e45d386df787ff56ad26c5e30 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sun, 19 Jul 2026 00:36:15 +0430 Subject: [PATCH 05/33] fix: use type import for pending Concierge reload test --- tests/unit/navigateAfterOnboardingTest.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 910917f0d93d..1fd31a3a3362 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,6 +1,7 @@ import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; +import type * as PendingConciergeDeepLink from '@libs/PendingConciergeDeepLink'; import type * as ReportUtils from '@libs/ReportUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -229,8 +230,7 @@ describe('navigateAfterOnboarding', () => { setPendingConciergeDeepLink(); jest.isolateModules(() => { - const {consumePendingConciergeDeepLink: consumePendingConciergeDeepLinkAfterReload} = - jest.requireActual('@libs/PendingConciergeDeepLink'); + const {consumePendingConciergeDeepLink: consumePendingConciergeDeepLinkAfterReload} = jest.requireActual('@libs/PendingConciergeDeepLink'); expect(consumePendingConciergeDeepLinkAfterReload()).toBe(true); }); From 3f4a0eb16fc930432abf23209595253e7de3e967 Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 22 Jul 2026 02:15:40 +0430 Subject: [PATCH 06/33] Fix Concierge onboarding deep link persistence and cancellation --- src/DeepLinkHandler.tsx | 5 + .../Navigation/linkingConfig/subscribe.ts | 46 ++++++- src/libs/PendingConciergeDeepLink.ts | 106 +++++++++++++-- src/libs/actions/Link.ts | 11 +- src/libs/navigateAfterOnboarding.ts | 7 +- tests/unit/navigateAfterOnboardingTest.ts | 121 +++++++++++++++++- 6 files changed, 279 insertions(+), 17 deletions(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 3f23afdefabe..1a4c7f6413f5 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -12,6 +12,7 @@ import {openReportFromDeepLink} from './libs/actions/Link'; import * as Report from './libs/actions/Report'; import {hasAuthToken, isAnonymousUser} from './libs/actions/Session'; import Log from './libs/Log'; +import {setPendingHomeDeepLinkIfNoPendingConcierge} from './libs/PendingConciergeDeepLink'; import {getReportIDFromLink} from './libs/ReportUtils'; import {endSpan} from './libs/telemetry/activeSpans'; import ONYXKEYS from './ONYXKEYS'; @@ -106,6 +107,10 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { openReportFromDeepLink(url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { + if (!isCurrentlyAuthenticated && typeof window !== 'undefined' && window.location.pathname === '/') { + // A missing initial URL at root can happen during startup, so don't override a stored /concierge intent. + setPendingHomeDeepLinkIfNoPendingConcierge(); + } Report.doneCheckingPublicRoom(); } diff --git a/src/libs/Navigation/linkingConfig/subscribe.ts b/src/libs/Navigation/linkingConfig/subscribe.ts index cafdecca095b..e3a715e0eec2 100644 --- a/src/libs/Navigation/linkingConfig/subscribe.ts +++ b/src/libs/Navigation/linkingConfig/subscribe.ts @@ -1,7 +1,10 @@ import {hasAuthToken} from '@libs/actions/Session'; import continuePlaidOAuth from '@libs/continuePlaidOAuth'; +import isPublicScreenRoute from '@libs/isPublicScreenRoute'; +import normalizePath from '@libs/Navigation/helpers/normalizePath'; import navigationRef from '@libs/Navigation/navigationRef'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge} from '@libs/PendingConciergeDeepLink'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -11,6 +14,8 @@ import type {LinkingOptions} from '@react-navigation/native'; import {findFocusedRoute} from '@react-navigation/native'; import {Linking} from 'react-native'; +import prefixes from './prefixes'; + /** * Rules for dropping a deep link that would re-navigate to a screen the user is already on. */ @@ -29,8 +34,47 @@ const skipRules: ReadonlyArray<{urlMatcher: RegExp; focusedScreens: readonly str }, ]; +function isInternalAppURL(url: string) { + if (url.startsWith('/') || prefixes.some((prefix) => url.startsWith(prefix))) { + return true; + } + + try { + return typeof window !== 'undefined' && new URL(url).origin === window.location.origin; + } catch { + return false; + } +} + +function getNormalizedPathFromURL(url: string) { + let path = url; + + try { + const parsedURL = new URL(url); + path = parsedURL.protocol === 'http:' || parsedURL.protocol === 'https:' || parsedURL.pathname ? parsedURL.pathname : parsedURL.host; + } catch { + // If URL parsing fails, treat the value as a route path. + } + + return (normalizePath(path).replace(/\/$/, '') || '/').toLowerCase(); +} + const subscribe: LinkingOptions['subscribe'] = (listener) => { const subscription = Linking.addEventListener('url', ({url}: {url: string}) => { + const isAuthenticated = hasAuthToken(); + const normalizedPath = getNormalizedPathFromURL(url); + const route = normalizedPath === '/' ? '' : normalizedPath.slice(1); + if (!isAuthenticated && isInternalAppURL(url)) { + if (normalizedPath === normalizePath(ROUTES.CONCIERGE)) { + setPendingConciergeDeepLink(); + } else if (normalizedPath === '/' || normalizedPath === normalizePath(ROUTES.HOME)) { + // URL events can be emitted by navigation restoration, so keep a persisted Concierge intent if one exists. + setPendingHomeDeepLinkIfNoPendingConcierge(); + } else if (!isPublicScreenRoute(route)) { + clearPendingConciergeDeepLink(); + } + } + // Skip deep links to screens where the user is already focused. const skipRule = skipRules.find(({urlMatcher}) => urlMatcher.test(url)); if (skipRule) { @@ -55,7 +99,7 @@ const subscribe: LinkingOptions['subscribe'] = (listener // which lives in AuthScreens and is not mounted while PublicScreens is showing. Dispatching it here // throws "NAVIGATE ... was not handled by any navigator". openReportFromDeepLink() already opens the // public room as an anonymous user and handles navigation, so defer to it instead. See #92672. - if (!hasAuthToken() && url.includes(`/${ROUTES.REPORT}/`)) { + if (!isAuthenticated && url.includes(`/${ROUTES.REPORT}/`)) { return; } listener(url); diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 52c611804c86..61b19a7a2269 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -1,10 +1,15 @@ /** * Tracks whether a logged-out user opened a /concierge deep link so the app can * route them to Concierge after sign-up/onboarding. sessionStorage keeps the - * tab-scoped intent across page reloads while sign-out/consume paths clear it. + * tab-scoped intent across page reloads, while an explicit root deep link can + * replace it with a Home fallback when the user cancels the Concierge intent. */ const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; +const PENDING_HOME_DEEP_LINK_STORAGE_KEY = 'PENDING_HOME_DEEP_LINK'; +const LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD = 1; +type LegacyPerformance = Performance & {navigation?: {type?: number}}; let hasPendingConciergeDeepLink = false; +let hasPendingHomeDeepLink = false; function getSessionStorage() { try { @@ -14,36 +19,111 @@ function getSessionStorage() { } } -function hasStoredPendingConciergeDeepLink() { +function hasStoredFlag(key: string) { try { - return getSessionStorage()?.getItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY) === 'true'; + return getSessionStorage()?.getItem(key) === 'true'; } catch { return false; } } -function setPendingConciergeDeepLink() { - hasPendingConciergeDeepLink = true; +function setStoredFlag(key: string) { try { - getSessionStorage()?.setItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY, 'true'); + getSessionStorage()?.setItem(key, 'true'); } catch { // Ignore storage failures and keep the in-memory intent for the current page lifecycle. } } -function consumePendingConciergeDeepLink() { - const shouldNavigateToConcierge = hasPendingConciergeDeepLink || hasStoredPendingConciergeDeepLink(); - clearPendingConciergeDeepLink(); - return shouldNavigateToConcierge; +function clearStoredFlag(key: string) { + try { + getSessionStorage()?.removeItem(key); + } catch { + // Ignore storage failures since clearing the in-memory flag is still enough for this page lifecycle. + } +} + +function hasPendingConciergeDeepLinkIntent() { + return hasPendingConciergeDeepLink || hasStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); +} + +function hasPendingHomeDeepLinkIntent() { + return hasPendingHomeDeepLink || hasStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); +} + +function clearPendingHomeDeepLink() { + hasPendingHomeDeepLink = false; + clearStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); } function clearPendingConciergeDeepLink() { hasPendingConciergeDeepLink = false; + clearPendingHomeDeepLink(); + clearStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); +} + +function setPendingHomeDeepLink() { + clearPendingConciergeDeepLink(); + hasPendingHomeDeepLink = true; + setStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); +} + +function isBrowserReload() { try { - getSessionStorage()?.removeItem(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); + // A browser refresh during signup can replay the root route even though the stored Concierge intent is still valid. + const performance = typeof window === 'undefined' ? undefined : window.performance; + const navigationEntries = performance?.getEntriesByType?.('navigation') ?? []; + if (navigationEntries.some((entry) => 'type' in entry && entry.type === 'reload')) { + return true; + } + + // Some web runtimes only expose the deprecated navigation API, so keep it as a fallback for reload detection. + return (performance as LegacyPerformance | undefined)?.navigation?.type === LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD; } catch { - // Ignore storage failures since clearing the in-memory flag is still enough for this page lifecycle. + return false; + } +} + +function setPendingHomeDeepLinkIfNoPendingConcierge() { + // Startup/linking can emit ambiguous root/home signals, so avoid replacing an explicit /concierge intent. + if (hasPendingConciergeDeepLinkIntent()) { + return; } + setPendingHomeDeepLink(); +} + +function setPendingHomeDeepLinkForRoot() { + // A non-reload root URL is the user's latest explicit intent and should cancel any pending Concierge redirect. + if (isBrowserReload()) { + setPendingHomeDeepLinkIfNoPendingConcierge(); + return; + } + setPendingHomeDeepLink(); +} + +function setPendingConciergeDeepLink() { + clearPendingHomeDeepLink(); + hasPendingConciergeDeepLink = true; + setStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); +} + +function consumePendingHomeDeepLink() { + const shouldNavigateHome = hasPendingHomeDeepLinkIntent(); + clearPendingHomeDeepLink(); + return shouldNavigateHome; +} + +function consumePendingConciergeDeepLink() { + const shouldNavigateToConcierge = hasPendingConciergeDeepLinkIntent(); + clearPendingConciergeDeepLink(); + return shouldNavigateToConcierge; } -export {setPendingConciergeDeepLink, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink}; +export { + setPendingConciergeDeepLink, + setPendingHomeDeepLinkForRoot, + setPendingHomeDeepLinkIfNoPendingConcierge, + consumePendingConciergeDeepLink, + consumePendingHomeDeepLink, + clearPendingConciergeDeepLink, +}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index cb86eb613d09..18ecb98fc174 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -16,7 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import {getIsOffline} from '@libs/NetworkState'; -import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, setPendingHomeDeepLinkForRoot, setPendingHomeDeepLinkIfNoPendingConcierge} from '@libs/PendingConciergeDeepLink'; import {findLastAccessedReport, getReportIDFromLink, getReportOrDraftReport, getRouteFromLink, isMoneyRequestReport} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -503,9 +503,18 @@ function openReportFromDeepLink( route = ''; } + const normalizedRoute = normalizePath(route); + const isRootRoute = !route || normalizedRoute === '/'; + const isHomeRoute = normalizedRoute === normalizePath(ROUTES.HOME); if (!isAuthenticated) { if (isConciergeRoute(route)) { setPendingConciergeDeepLink(); + } else if (isRootRoute) { + // Root is a normal signup intent, but browser reloads can replay root after a preserved Concierge intent. + setPendingHomeDeepLinkForRoot(); + } else if (isHomeRoute) { + // /home can be generated during auth/startup reloads, so keep an existing Concierge intent if one is already stored. + setPendingHomeDeepLinkIfNoPendingConcierge(); } else if (!isPublicScreenRoute(route)) { clearPendingConciergeDeepLink(); } diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 1eb131113ab3..609628ac9eac 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -17,7 +17,7 @@ import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostS import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; -import {consumePendingConciergeDeepLink} from './PendingConciergeDeepLink'; +import {consumePendingConciergeDeepLink, consumePendingHomeDeepLink} from './PendingConciergeDeepLink'; import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; let onboardingRHPVariant: OnyxEntry; @@ -82,11 +82,13 @@ function navigateAfterOnboarding( // (Side Panel doesn't exist on native), but we still need to navigate to Concierge on mobile. const variant = variantOverride ?? onboardingRHPVariant; if (isSmallScreenWidth && variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { + consumePendingHomeDeepLink(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID)); return; } if (shouldOpenRHPVariant(variantOverride)) { + consumePendingHomeDeepLink(); handleRHPVariantNavigation(onboardingPolicyID, variantOverride); return; } @@ -101,7 +103,10 @@ function navigateAfterOnboarding( shouldPreventOpenAdminRoom, ); if (reportID) { + consumePendingHomeDeepLink(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } else if (consumePendingHomeDeepLink()) { + Navigation.navigate(ROUTES.HOME); } else if (consumePendingConciergeDeepLink()) { Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); } else if (!isReportTopmostSplitNavigator()) { diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 1fd31a3a3362..f488a70a0817 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,6 +1,7 @@ +import {openReportFromDeepLink} from '@libs/actions/Link'; import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge} from '@libs/PendingConciergeDeepLink'; import type * as PendingConciergeDeepLink from '@libs/PendingConciergeDeepLink'; import type * as ReportUtils from '@libs/ReportUtils'; @@ -25,6 +26,41 @@ const mockFindLastAccessedReport = jest.fn, Parameters false); +function mockBrowserReloadNavigation(useLegacyFallback = false) { + const originalGetEntriesByType = Object.getOwnPropertyDescriptor(window.performance, 'getEntriesByType'); + const originalNavigation = Object.getOwnPropertyDescriptor(window.performance, 'navigation'); + Object.defineProperty(window.performance, 'getEntriesByType', { + configurable: true, + value: jest.fn((type: string) => { + if (type !== 'navigation') { + return []; + } + return useLegacyFallback ? [] : [{type: 'reload'} as unknown as PerformanceNavigationTiming]; + }), + }); + + if (useLegacyFallback) { + Object.defineProperty(window.performance, 'navigation', { + configurable: true, + value: {type: 1}, + }); + } + + return () => { + if (originalGetEntriesByType) { + Object.defineProperty(window.performance, 'getEntriesByType', originalGetEntriesByType); + } else { + Reflect.deleteProperty(window.performance, 'getEntriesByType'); + } + + if (originalNavigation) { + Object.defineProperty(window.performance, 'navigation', originalNavigation); + } else { + Reflect.deleteProperty(window.performance, 'navigation'); + } + }; +} + jest.mock('@expensify/react-native-hybrid-app', () => ({ __esModule: true, default: { @@ -56,6 +92,8 @@ jest.mock('@react-navigation/native', () => { jest.mock('@libs/ReportUtils', () => ({ findLastAccessedReport: (...args: Parameters) => mockFindLastAccessedReport(...args), + getReportIDFromLink: jest.requireActual('@libs/ReportUtils').getReportIDFromLink, + getRouteFromLink: jest.requireActual('@libs/ReportUtils').getRouteFromLink, parseReportRouteParams: jest.fn(() => ({})), isConciergeChatReport: jest.requireActual('@libs/ReportUtils').isConciergeChatReport, isArchivedReport: jest.requireActual('@libs/ReportUtils').isArchivedReport, @@ -237,4 +275,85 @@ describe('navigateAfterOnboarding', () => { expect(window.sessionStorage.getItem('PENDING_CONCIERGE_DEEP_LINK')).toBeNull(); clearPendingConciergeDeepLink(); }); + + it('should preserve a pending Concierge deep link when a generated home route is processed during reload', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + setPendingConciergeDeepLink(); + + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/${ROUTES.HOME}`, {}, false, REPORT_ID, undefined, undefined, undefined); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + }); + + it('should not let an ambiguous home fallback override a pending Concierge deep link', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + setPendingConciergeDeepLink(); + + setPendingHomeDeepLinkIfNoPendingConcierge(); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + }); + + it('should clear a stale pending Concierge deep link when opening root before onboarding finishes', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + setPendingConciergeDeepLink(); + + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + }); + + it('should preserve a pending Concierge deep link when root is replayed during a browser reload', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + const restoreBrowserNavigation = mockBrowserReloadNavigation(); + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + setPendingConciergeDeepLink(); + + try { + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + } finally { + restoreBrowserNavigation(); + } + }); + + it('should preserve a pending Concierge deep link when browser reload is only available from the legacy navigation API', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + const restoreBrowserNavigation = mockBrowserReloadNavigation(true); + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + setPendingConciergeDeepLink(); + + try { + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + } finally { + restoreBrowserNavigation(); + } + }); + + it('should let the normal onboarding destination win after root clears a stale pending Concierge deep link', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + }); }); From 74735c9a5851568290dffac3376c95efc5a591f7 Mon Sep 17 00:00:00 2001 From: X Developer Date: Wed, 22 Jul 2026 02:35:21 +0430 Subject: [PATCH 07/33] Fix lint-safe Concierge deep link reload detection --- src/libs/PendingConciergeDeepLink.ts | 12 +++++++++--- tests/unit/navigateAfterOnboardingTest.ts | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 61b19a7a2269..4065fa1b47a4 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -6,8 +6,9 @@ */ const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; const PENDING_HOME_DEEP_LINK_STORAGE_KEY = 'PENDING_HOME_DEEP_LINK'; +const LEGACY_PERFORMANCE_NAVIGATION_KEY = 'navigation'; +const LEGACY_PERFORMANCE_NAVIGATION_TYPE_KEY = 'type'; const LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD = 1; -type LegacyPerformance = Performance & {navigation?: {type?: number}}; let hasPendingConciergeDeepLink = false; let hasPendingHomeDeepLink = false; @@ -68,6 +69,10 @@ function setPendingHomeDeepLink() { setStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + function isBrowserReload() { try { // A browser refresh during signup can replay the root route even though the stored Concierge intent is still valid. @@ -77,8 +82,9 @@ function isBrowserReload() { return true; } - // Some web runtimes only expose the deprecated navigation API, so keep it as a fallback for reload detection. - return (performance as LegacyPerformance | undefined)?.navigation?.type === LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD; + // Some web runtimes only expose the deprecated navigation API, so read it indirectly to keep the fallback without triggering deprecated API lint. + const legacyNavigation: unknown = performance ? Reflect.get(performance, LEGACY_PERFORMANCE_NAVIGATION_KEY) : undefined; + return isRecord(legacyNavigation) && legacyNavigation[LEGACY_PERFORMANCE_NAVIGATION_TYPE_KEY] === LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD; } catch { return false; } diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index f488a70a0817..c9fa5668947c 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -35,7 +35,7 @@ function mockBrowserReloadNavigation(useLegacyFallback = false) { if (type !== 'navigation') { return []; } - return useLegacyFallback ? [] : [{type: 'reload'} as unknown as PerformanceNavigationTiming]; + return useLegacyFallback ? [] : [{type: 'reload'}]; }), }); From 71c10c74a759ef878d927e67d5daabfaa87f881a Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 23 Jul 2026 01:12:03 +0430 Subject: [PATCH 08/33] Fix Concierge deep-link intent through onboarding edge cases --- .../Navigation/linkingConfig/subscribe.ts | 14 +--- src/libs/PendingConciergeDeepLink.ts | 34 ++++++++ src/libs/actions/Link.ts | 25 +----- src/libs/navigateAfterOnboarding.ts | 26 ++++-- tests/unit/navigateAfterOnboardingTest.ts | 83 ++++++++++++++++++- 5 files changed, 139 insertions(+), 43 deletions(-) diff --git a/src/libs/Navigation/linkingConfig/subscribe.ts b/src/libs/Navigation/linkingConfig/subscribe.ts index e3a715e0eec2..91b6b0ff938d 100644 --- a/src/libs/Navigation/linkingConfig/subscribe.ts +++ b/src/libs/Navigation/linkingConfig/subscribe.ts @@ -1,10 +1,9 @@ import {hasAuthToken} from '@libs/actions/Session'; import continuePlaidOAuth from '@libs/continuePlaidOAuth'; -import isPublicScreenRoute from '@libs/isPublicScreenRoute'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; import navigationRef from '@libs/Navigation/navigationRef'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; -import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge} from '@libs/PendingConciergeDeepLink'; +import {updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -59,20 +58,13 @@ function getNormalizedPathFromURL(url: string) { return (normalizePath(path).replace(/\/$/, '') || '/').toLowerCase(); } -const subscribe: LinkingOptions['subscribe'] = (listener) => { +const subscribe: NonNullable['subscribe']> = (listener) => { const subscription = Linking.addEventListener('url', ({url}: {url: string}) => { const isAuthenticated = hasAuthToken(); const normalizedPath = getNormalizedPathFromURL(url); const route = normalizedPath === '/' ? '' : normalizedPath.slice(1); if (!isAuthenticated && isInternalAppURL(url)) { - if (normalizedPath === normalizePath(ROUTES.CONCIERGE)) { - setPendingConciergeDeepLink(); - } else if (normalizedPath === '/' || normalizedPath === normalizePath(ROUTES.HOME)) { - // URL events can be emitted by navigation restoration, so keep a persisted Concierge intent if one exists. - setPendingHomeDeepLinkIfNoPendingConcierge(); - } else if (!isPublicScreenRoute(route)) { - clearPendingConciergeDeepLink(); - } + updatePendingConciergeDeepLinkForRoute(route, isAuthenticated); } // Skip deep links to screens where the user is already focused. diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 4065fa1b47a4..a815e59b2a9e 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -1,3 +1,8 @@ +import ROUTES from '@src/ROUTES'; + +import isPublicScreenRoute from './isPublicScreenRoute'; +import normalizePath from './Navigation/helpers/normalizePath'; + /** * Tracks whether a logged-out user opened a /concierge deep link so the app can * route them to Concierge after sign-up/onboarding. sessionStorage keeps the @@ -113,6 +118,34 @@ function setPendingConciergeDeepLink() { setStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); } +function getNormalizedRouteWithoutParams(route: string) { + const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); + return routeWithoutParams.replace(/\/$/, '') || '/'; +} + +// Keep pending signup deep-link intent consistent across initial URL handling and later Linking URL events. +function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: boolean) { + if (isAuthenticated) { + return; + } + + const normalizedRoute = getNormalizedRouteWithoutParams(route); + const routeForPublicScreen = normalizedRoute === '/' ? '' : normalizedRoute.slice(1); + + if (normalizedRoute === normalizePath(ROUTES.CONCIERGE)) { + setPendingConciergeDeepLink(); + } else if (normalizedRoute === '/') { + // Root is an explicit normal signup intent, so it cancels Concierge unless it is a reload replay. + setPendingHomeDeepLinkForRoot(); + } else if (normalizedRoute === normalizePath(ROUTES.HOME)) { + // /home can be generated during auth/startup reloads, so keep an existing Concierge intent if one is already stored. + setPendingHomeDeepLinkIfNoPendingConcierge(); + } else if (!isPublicScreenRoute(routeForPublicScreen)) { + // A different protected/internal deep link should not inherit an older Concierge redirect. + clearPendingConciergeDeepLink(); + } +} + function consumePendingHomeDeepLink() { const shouldNavigateHome = hasPendingHomeDeepLinkIntent(); clearPendingHomeDeepLink(); @@ -129,6 +162,7 @@ export { setPendingConciergeDeepLink, setPendingHomeDeepLinkForRoot, setPendingHomeDeepLinkIfNoPendingConcierge, + updatePendingConciergeDeepLinkForRoute, consumePendingConciergeDeepLink, consumePendingHomeDeepLink, clearPendingConciergeDeepLink, diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 18ecb98fc174..2cef8628a920 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -16,7 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import {getIsOffline} from '@libs/NetworkState'; -import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, setPendingHomeDeepLinkForRoot, setPendingHomeDeepLinkIfNoPendingConcierge} from '@libs/PendingConciergeDeepLink'; +import {updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; import {findLastAccessedReport, getReportIDFromLink, getReportOrDraftReport, getRouteFromLink, isMoneyRequestReport} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -452,12 +452,6 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { openExternalLink(href); } -function isConciergeRoute(route: string) { - const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); - const normalizedRoute = routeWithoutParams.replace(/\/$/, ''); - return normalizedRoute === normalizePath(ROUTES.CONCIERGE); -} - function openReportFromDeepLink( url: string, reports: OnyxCollection, @@ -503,22 +497,7 @@ function openReportFromDeepLink( route = ''; } - const normalizedRoute = normalizePath(route); - const isRootRoute = !route || normalizedRoute === '/'; - const isHomeRoute = normalizedRoute === normalizePath(ROUTES.HOME); - if (!isAuthenticated) { - if (isConciergeRoute(route)) { - setPendingConciergeDeepLink(); - } else if (isRootRoute) { - // Root is a normal signup intent, but browser reloads can replay root after a preserved Concierge intent. - setPendingHomeDeepLinkForRoot(); - } else if (isHomeRoute) { - // /home can be generated during auth/startup reloads, so keep an existing Concierge intent if one is already stored. - setPendingHomeDeepLinkIfNoPendingConcierge(); - } else if (!isPublicScreenRoute(route)) { - clearPendingConciergeDeepLink(); - } - } + updatePendingConciergeDeepLinkForRoute(route, isAuthenticated); // If we are not authenticated and are navigating to a public screen, we don't want to navigate again to the screen after sign-in/sign-up if (!isAuthenticated && isPublicScreenRoute(route)) { diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 609628ac9eac..3f52bb7185a9 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -64,6 +64,11 @@ function getReportIDAfterOnboarding( return undefined; } +function isConciergeOnboardingVariant(variant: OnboardingRHPVariant | null | undefined) { + // These onboarding variants can open Concierge even after the pending /concierge deep-link flag has been cleared. + return variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE || variant === CONST.ONBOARDING_RHP_VARIANT.RHP_CONCIERGE_DM; +} + function navigateAfterOnboarding( isSmallScreenWidth: boolean, canUseDefaultRooms: boolean | undefined, @@ -76,19 +81,27 @@ function navigateAfterOnboarding( ) { setDisableDismissOnEscape(false); + // Resolve signup deep-link intents before onboarding variants, so /concierge wins unless the user explicitly replaced it with /. + const shouldNavigateHomeFromDeepLink = consumePendingHomeDeepLink(); + const shouldNavigateToConciergeFromDeepLink = consumePendingConciergeDeepLink(); + if (!shouldNavigateHomeFromDeepLink && shouldNavigateToConciergeFromDeepLink) { + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + return; + } + // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native // (Side Panel doesn't exist on native), but we still need to navigate to Concierge on mobile. const variant = variantOverride ?? onboardingRHPVariant; - if (isSmallScreenWidth && variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { - consumePendingHomeDeepLink(); + // If the user opened / after /concierge, keep that latest Home intent from being overridden by Concierge-specific onboarding variants. + const shouldBlockConciergeOnboardingVariant = shouldNavigateHomeFromDeepLink && isConciergeOnboardingVariant(variant); + if (!shouldBlockConciergeOnboardingVariant && isSmallScreenWidth && variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID)); return; } - if (shouldOpenRHPVariant(variantOverride)) { - consumePendingHomeDeepLink(); + if (!shouldBlockConciergeOnboardingVariant && shouldOpenRHPVariant(variantOverride)) { handleRHPVariantNavigation(onboardingPolicyID, variantOverride); return; } @@ -103,12 +116,9 @@ function navigateAfterOnboarding( shouldPreventOpenAdminRoom, ); if (reportID) { - consumePendingHomeDeepLink(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - } else if (consumePendingHomeDeepLink()) { + } else if (shouldNavigateHomeFromDeepLink) { Navigation.navigate(ROUTES.HOME); - } else if (consumePendingConciergeDeepLink()) { - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); } else if (!isReportTopmostSplitNavigator()) { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index c9fa5668947c..bd5155a22763 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,7 +1,15 @@ import {openReportFromDeepLink} from '@libs/actions/Link'; +import SidePanelActions from '@libs/actions/SidePanel'; import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge} from '@libs/PendingConciergeDeepLink'; +import { + clearPendingConciergeDeepLink, + consumePendingConciergeDeepLink, + consumePendingHomeDeepLink, + setPendingConciergeDeepLink, + setPendingHomeDeepLinkIfNoPendingConcierge, + updatePendingConciergeDeepLinkForRoute, +} from '@libs/PendingConciergeDeepLink'; import type * as PendingConciergeDeepLink from '@libs/PendingConciergeDeepLink'; import type * as ReportUtils from '@libs/ReportUtils'; @@ -119,6 +127,13 @@ jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => ({ default: () => mockIsReportTopmostSplitNavigator(), })); +jest.mock('@libs/actions/SidePanel', () => ({ + __esModule: true, + default: { + openSidePanel: jest.fn(), + }, +})); + describe('navigateAfterOnboarding', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -243,6 +258,26 @@ describe('navigateAfterOnboarding', () => { expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); + it('should navigate to Concierge instead of the onboarding admin room when a pending Concierge deep link is available', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); + }); + + it('should navigate to Concierge instead of the onboarding RHP variant when a pending Concierge deep link is available', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); + }); + it('should navigate to Concierge route when pending deep link is set but conciergeReportID is empty', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); @@ -300,6 +335,52 @@ describe('navigateAfterOnboarding', () => { expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); + it('should treat an unauthenticated root route as an explicit Home intent', () => { + setPendingConciergeDeepLink(); + + updatePendingConciergeDeepLinkForRoute('', false); + + expect(consumePendingHomeDeepLink()).toBe(true); + expect(consumePendingConciergeDeepLink()).toBe(false); + }); + + it('should preserve a pending Concierge intent for generated Home routes', () => { + setPendingConciergeDeepLink(); + + updatePendingConciergeDeepLinkForRoute(ROUTES.HOME, false); + + expect(consumePendingConciergeDeepLink()).toBe(true); + expect(consumePendingHomeDeepLink()).toBe(false); + }); + + it('should block the track expenses Concierge variant after an explicit Home deep link', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + const openSidePanel = jest.mocked(SidePanelActions.openSidePanel); + setPendingConciergeDeepLink(); + + updatePendingConciergeDeepLinkForRoute('', false); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE); + + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(openSidePanel).not.toHaveBeenCalled(); + }); + + it('should block the Concierge RHP variant after an explicit Home deep link', async () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + const openSidePanel = jest.mocked(SidePanelActions.openSidePanel); + setPendingConciergeDeepLink(); + await Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, CONST.ONBOARDING_COMPANY_SIZE.MICRO); + await waitForBatchedUpdates(); + + updatePendingConciergeDeepLinkForRoute('', false); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.RHP_CONCIERGE_DM); + + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.WORKSPACE_OVERVIEW.getRoute(ONBOARDING_POLICY_ID)); + expect(openSidePanel).not.toHaveBeenCalled(); + }); + it('should clear a stale pending Concierge deep link when opening root before onboarding finishes', () => { const navigate = jest.spyOn(Navigation, 'navigate'); mockIsReportTopmostSplitNavigator.mockReturnValue(true); From e181a8c5b748903bce3149ebcad80dcbca4d14f3 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 25 Jul 2026 11:48:09 +0430 Subject: [PATCH 09/33] Fix Concierge deep link after onboarding refresh flows --- src/hooks/useAutoCreateSubmitWorkspace.ts | 4 +- src/libs/PendingConciergeDeepLink.ts | 110 +++++++++++++++--- src/libs/navigateAfterOnboarding.ts | 48 +++++--- .../BaseOnboardingWorkspaces.tsx | 3 +- .../useAutoCreateSubmitWorkspace.test.ts | 21 +++- .../unit/libs/navigateAfterOnboarding.test.ts | 23 ++++ tests/unit/navigateAfterOnboardingTest.ts | 68 ++++++++++- 7 files changed, 236 insertions(+), 41 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 8834f12413af..84c8a6c87f6e 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -121,7 +121,8 @@ function useAutoCreateSubmitWorkspace() { policyIDForNavigation = existingSubmitPolicyID; } - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); + // Pass conciergeReportID so the Submit workspace completion path can honor a pending /concierge intent after refresh. + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID); }, [ currentUserEmail, @@ -143,6 +144,7 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, shouldUseNarrowLayout, conciergeChat, + conciergeReportID, ], ); diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index a815e59b2a9e..1659f020c820 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -6,16 +6,19 @@ import normalizePath from './Navigation/helpers/normalizePath'; /** * Tracks whether a logged-out user opened a /concierge deep link so the app can * route them to Concierge after sign-up/onboarding. sessionStorage keeps the - * tab-scoped intent across page reloads, while an explicit root deep link can - * replace it with a Home fallback when the user cancels the Concierge intent. + * tab-scoped intent across page reloads, while localStorage lets any explicit + * non-Concierge deep link in another tab cancel older Concierge intents for the same browser. */ const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; +const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET'; +const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN'; const PENDING_HOME_DEEP_LINK_STORAGE_KEY = 'PENDING_HOME_DEEP_LINK'; const LEGACY_PERFORMANCE_NAVIGATION_KEY = 'navigation'; const LEGACY_PERFORMANCE_NAVIGATION_TYPE_KEY = 'type'; const LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD = 1; let hasPendingConciergeDeepLink = false; let hasPendingHomeDeepLink = false; +let pendingConciergeCancelTokenAtSet = ''; function getSessionStorage() { try { @@ -25,34 +28,85 @@ function getSessionStorage() { } } -function hasStoredFlag(key: string) { +function getLocalStorage() { try { - return getSessionStorage()?.getItem(key) === 'true'; + return typeof window === 'undefined' ? undefined : window.localStorage; } catch { - return false; + return undefined; } } -function setStoredFlag(key: string) { +function getStoredValue(key: string, getStorage: () => Storage | undefined) { try { - getSessionStorage()?.setItem(key, 'true'); + return getStorage()?.getItem(key); + } catch { + return undefined; + } +} + +function setStoredValue(key: string, value: string, getStorage: () => Storage | undefined) { + try { + getStorage()?.setItem(key, value); } catch { // Ignore storage failures and keep the in-memory intent for the current page lifecycle. } } -function clearStoredFlag(key: string) { +function clearStoredValue(key: string, getStorage: () => Storage | undefined) { try { - getSessionStorage()?.removeItem(key); + getStorage()?.removeItem(key); } catch { // Ignore storage failures since clearing the in-memory flag is still enough for this page lifecycle. } } -function hasPendingConciergeDeepLinkIntent() { +function hasStoredFlag(key: string) { + return getStoredValue(key, getSessionStorage) === 'true'; +} + +function setStoredFlag(key: string) { + setStoredValue(key, 'true', getSessionStorage); +} + +function clearStoredFlag(key: string) { + clearStoredValue(key, getSessionStorage); +} + +function getCancelToken() { + return getStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, getLocalStorage) ?? ''; +} + +function setCancelToken() { + setStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, `${Date.now()}-${Math.random()}`, getLocalStorage); +} + +function setPendingConciergeCancelTokenAtSet() { + pendingConciergeCancelTokenAtSet = getCancelToken(); + setStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY, pendingConciergeCancelTokenAtSet, getSessionStorage); +} + +function clearPendingConciergeCancelTokenAtSet() { + pendingConciergeCancelTokenAtSet = ''; + clearStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY, getSessionStorage); +} + +function hasPendingConciergeDeepLinkFlag() { return hasPendingConciergeDeepLink || hasStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); } +function hasCancelTokenChangedSinceConciergeWasSet() { + if (!hasPendingConciergeDeepLinkFlag()) { + return false; + } + + // A newer cancel token means another tab opened a non-Concierge route after this tab stored /concierge. + return getCancelToken() !== (getStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY, getSessionStorage) ?? pendingConciergeCancelTokenAtSet); +} + +function hasPendingConciergeDeepLinkIntent() { + return hasPendingConciergeDeepLinkFlag() && !hasCancelTokenChangedSinceConciergeWasSet(); +} + function hasPendingHomeDeepLinkIntent() { return hasPendingHomeDeepLink || hasStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); } @@ -66,6 +120,7 @@ function clearPendingConciergeDeepLink() { hasPendingConciergeDeepLink = false; clearPendingHomeDeepLink(); clearStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); + clearPendingConciergeCancelTokenAtSet(); } function setPendingHomeDeepLink() { @@ -109,6 +164,7 @@ function setPendingHomeDeepLinkForRoot() { setPendingHomeDeepLinkIfNoPendingConcierge(); return; } + setCancelToken(); setPendingHomeDeepLink(); } @@ -116,6 +172,13 @@ function setPendingConciergeDeepLink() { clearPendingHomeDeepLink(); hasPendingConciergeDeepLink = true; setStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); + setPendingConciergeCancelTokenAtSet(); +} + +function cancelPendingConciergeDeepLinkFromExplicitRoute() { + // Share explicit non-Concierge route intent across tabs so stale /concierge signup flows are canceled everywhere. + setCancelToken(); + clearPendingConciergeDeepLink(); } function getNormalizedRouteWithoutParams(route: string) { @@ -123,15 +186,29 @@ function getNormalizedRouteWithoutParams(route: string) { return routeWithoutParams.replace(/\/$/, '') || '/'; } +function isOnboardingRoute(normalizedRoute: string) { + // Onboarding URLs are generated by the guided setup flow, so they should not replace the original signup deep-link intent. + return normalizedRoute === normalizePath(ROUTES.ONBOARDING_ROOT.route) || normalizedRoute.startsWith(`${normalizePath(ROUTES.ONBOARDING_ROOT.route)}/`); +} + // Keep pending signup deep-link intent consistent across initial URL handling and later Linking URL events. function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: boolean) { + const normalizedRoute = getNormalizedRouteWithoutParams(route); + const routeForPublicScreen = normalizedRoute === '/' ? '' : normalizedRoute.slice(1); if (isAuthenticated) { + // Authenticated URL events can arrive after signup but before onboarding consumes the pending route intent. + if (normalizedRoute === '/') { + // Root can be opened after signup but before onboarding finishes, so keep it as an explicit Home intent. + setPendingHomeDeepLinkForRoot(); + } else if (isOnboardingRoute(normalizedRoute)) { + // Refreshing during onboarding should not replace the original signup deep-link intent. + return; + } else if (normalizedRoute !== normalizePath(ROUTES.CONCIERGE) && normalizedRoute !== normalizePath(ROUTES.HOME) && !isPublicScreenRoute(routeForPublicScreen)) { + cancelPendingConciergeDeepLinkFromExplicitRoute(); + } return; } - const normalizedRoute = getNormalizedRouteWithoutParams(route); - const routeForPublicScreen = normalizedRoute === '/' ? '' : normalizedRoute.slice(1); - if (normalizedRoute === normalizePath(ROUTES.CONCIERGE)) { setPendingConciergeDeepLink(); } else if (normalizedRoute === '/') { @@ -142,13 +219,16 @@ function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: setPendingHomeDeepLinkIfNoPendingConcierge(); } else if (!isPublicScreenRoute(routeForPublicScreen)) { // A different protected/internal deep link should not inherit an older Concierge redirect. - clearPendingConciergeDeepLink(); + cancelPendingConciergeDeepLinkFromExplicitRoute(); } } function consumePendingHomeDeepLink() { - const shouldNavigateHome = hasPendingHomeDeepLinkIntent(); + const shouldNavigateHome = hasPendingHomeDeepLinkIntent() || hasCancelTokenChangedSinceConciergeWasSet(); clearPendingHomeDeepLink(); + if (shouldNavigateHome) { + clearPendingConciergeDeepLink(); + } return shouldNavigateHome; } diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 3f52bb7185a9..521b2da63209 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -28,6 +28,25 @@ Onyx.connectWithoutView({ }, }); +function navigateToPendingDeepLinkAfterOnboarding(conciergeReportID?: string) { + const shouldNavigateHomeFromDeepLink = consumePendingHomeDeepLink(); + const shouldNavigateToConciergeFromDeepLink = consumePendingConciergeDeepLink(); + + if (shouldNavigateHomeFromDeepLink) { + // The latest explicit non-Concierge route should win before onboarding variants can open Concierge, an admin room, or a workspace. + Navigation.navigate(ROUTES.HOME); + return true; + } + + if (shouldNavigateToConciergeFromDeepLink) { + // The report ID can still be unavailable immediately after signup refresh, so fall back to the Concierge route. + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + return true; + } + + return false; +} + /** * Determines the report ID to navigate to after onboarding for control variant or ineligible users. * On large screens, navigates to the admins chat if available. On small screens, finds the last @@ -64,11 +83,6 @@ function getReportIDAfterOnboarding( return undefined; } -function isConciergeOnboardingVariant(variant: OnboardingRHPVariant | null | undefined) { - // These onboarding variants can open Concierge even after the pending /concierge deep-link flag has been cleared. - return variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE || variant === CONST.ONBOARDING_RHP_VARIANT.RHP_CONCIERGE_DM; -} - function navigateAfterOnboarding( isSmallScreenWidth: boolean, canUseDefaultRooms: boolean | undefined, @@ -82,10 +96,7 @@ function navigateAfterOnboarding( setDisableDismissOnEscape(false); // Resolve signup deep-link intents before onboarding variants, so /concierge wins unless the user explicitly replaced it with /. - const shouldNavigateHomeFromDeepLink = consumePendingHomeDeepLink(); - const shouldNavigateToConciergeFromDeepLink = consumePendingConciergeDeepLink(); - if (!shouldNavigateHomeFromDeepLink && shouldNavigateToConciergeFromDeepLink) { - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + if (navigateToPendingDeepLinkAfterOnboarding(conciergeReportID)) { return; } @@ -94,14 +105,12 @@ function navigateAfterOnboarding( // This check is outside shouldOpenRHPVariant because that function returns false on native // (Side Panel doesn't exist on native), but we still need to navigate to Concierge on mobile. const variant = variantOverride ?? onboardingRHPVariant; - // If the user opened / after /concierge, keep that latest Home intent from being overridden by Concierge-specific onboarding variants. - const shouldBlockConciergeOnboardingVariant = shouldNavigateHomeFromDeepLink && isConciergeOnboardingVariant(variant); - if (!shouldBlockConciergeOnboardingVariant && isSmallScreenWidth && variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { + if (isSmallScreenWidth && variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID)); return; } - if (!shouldBlockConciergeOnboardingVariant && shouldOpenRHPVariant(variantOverride)) { + if (shouldOpenRHPVariant(variantOverride)) { handleRHPVariantNavigation(onboardingPolicyID, variantOverride); return; } @@ -117,8 +126,6 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - } else if (shouldNavigateHomeFromDeepLink) { - Navigation.navigate(ROUTES.HOME); } else if (!isReportTopmostSplitNavigator()) { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME); @@ -155,9 +162,14 @@ function navigateAfterOnboardingWithMicrotaskQueue( * navigate to Workspace > Categories with the side panel open so * the #admins room is visible in Concierge Anywhere. */ -function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { setDisableDismissOnEscape(false); + // Submit workspace onboarding bypasses navigateAfterOnboarding(), so honor the same pending deep-link intent here. + if (navigateToPendingDeepLinkAfterOnboarding(conciergeReportID)) { + return; + } + if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -172,10 +184,10 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } -function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false) { +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { dismissOnboardingModalBeforeExit(); Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout); + navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout, conciergeReportID); }); } diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 36b7d9a14c94..4838eeba5c98 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -106,7 +106,8 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingPolicyID(policy.policyID); if (shouldUseSubmitFlow) { - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout); + // The Submit workspace path bypasses navigateAfterOnboarding(), so pass conciergeReportID for pending /concierge redirects. + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout, conciergeReportID); return; } diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts index 86b03581c251..31534c0d446e 100644 --- a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -41,7 +41,7 @@ const MOCK_POLICY_ID = 'mock-policy-id'; const MOCK_ADMINS_CHAT_REPORT_ID = 'mock-admins-chat-report-id'; const MOCK_ONBOARDING_MESSAGE = {message: 'Welcome!', video: undefined, tasks: []}; -function setupDefaultMocks() { +function setupDefaultMocks({conciergeReportID}: {conciergeReportID?: string} = {}) { mockUseOnyx.mockImplementation((key: string) => { if (key === 'session') { return [MOCK_SESSION]; @@ -49,6 +49,9 @@ function setupDefaultMocks() { if (key === 'betas') { return [[]]; } + if (key === 'conciergeReportID') { + return [conciergeReportID]; + } if (key.startsWith('policy_')) { return [false]; } @@ -166,7 +169,17 @@ describe('useAutoCreateSubmitWorkspace', () => { // Then the user should be navigated to the newly created Submit workspace // so they land on their workspace immediately after onboarding expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean)); + expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), undefined); + }); + + it('passes the Concierge report ID to submit workspace navigation when available', async () => { + setupDefaultMocks({conciergeReportID: 'concierge-report-id'}); + + const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); + await result.current('John', 'Doe'); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), 'concierge-report-id'); }); it('reuses the existing onboarding workspace instead of creating a new one', () => { @@ -315,7 +328,7 @@ describe('useAutoCreateSubmitWorkspace', () => { expect(createWorkspaceSpy).not.toHaveBeenCalled(); expect(completeOnboardingSpy).not.toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(existingSubmitPolicy.id, expect.any(Boolean)); + expect(navigateSpy).toHaveBeenCalledWith(existingSubmitPolicy.id, expect.any(Boolean), undefined); }); it('keeps the Home fallback for onboarding callers when creation is skipped', async () => { @@ -350,7 +363,7 @@ describe('useAutoCreateSubmitWorkspace', () => { // behavior (landing on Home) so this fix stays scoped to already-onboarded callers expect(createWorkspaceSpy).not.toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(undefined, expect.any(Boolean)); + expect(navigateSpy).toHaveBeenCalledWith(undefined, expect.any(Boolean), undefined); }); it('uses the localCurrencyCode from personal details for workspace currency', () => { diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index 171e6f6f0c9f..1b5572cf385a 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -1,5 +1,6 @@ import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; import ROUTES from '@src/ROUTES'; @@ -30,6 +31,7 @@ const navigationMock = Navigation as jest.Mocked; describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { beforeEach(() => { jest.clearAllMocks(); + clearPendingConciergeDeepLink(); }); it('navigates to HOME when policyID is missing', () => { @@ -57,4 +59,25 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { `${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACE_INITIAL.getRoute('test-policy-id'))}`, ); }); + + it('navigates to pending Concierge before Workspace Categories', () => { + setPendingConciergeDeepLink(); + + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false, 'concierge-report-id'); + + expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('concierge-report-id')); + }); + + it('navigates to Home before Workspace Categories when root replaced pending Concierge', () => { + setPendingConciergeDeepLink(); + updatePendingConciergeDeepLinkForRoute('', false); + + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false, 'concierge-report-id'); + + expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.HOME); + }); }); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index bd5155a22763..7bed1664e456 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -30,6 +30,7 @@ const ONBOARDING_ADMINS_CHAT_REPORT_ID = '1'; const ONBOARDING_POLICY_ID = '2'; const REPORT_ID = '3'; const USER_ID = '4'; +const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN'; const mockFindLastAccessedReport = jest.fn, Parameters>(); const mockShouldOpenOnAdminRoom = jest.fn(); const mockIsReportTopmostSplitNavigator = jest.fn(() => false); @@ -143,6 +144,7 @@ describe('navigateAfterOnboarding', () => { beforeEach(async () => { jest.clearAllMocks(); + window.localStorage.removeItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY); clearPendingConciergeDeepLink(); mockIsReportTopmostSplitNavigator.mockReturnValue(false); return Onyx.clear(); @@ -344,6 +346,45 @@ describe('navigateAfterOnboarding', () => { expect(consumePendingConciergeDeepLink()).toBe(false); }); + it('should treat an authenticated root route before onboarding finishes as an explicit Home intent', () => { + setPendingConciergeDeepLink(); + + updatePendingConciergeDeepLinkForRoute('', true); + + expect(consumePendingHomeDeepLink()).toBe(true); + expect(consumePendingConciergeDeepLink()).toBe(false); + }); + + it('should preserve pending Concierge intent when an authenticated onboarding route is replayed after refresh', () => { + setPendingConciergeDeepLink(); + + updatePendingConciergeDeepLinkForRoute(ROUTES.ONBOARDING_PURPOSE.route, true); + + expect(consumePendingConciergeDeepLink()).toBe(true); + expect(consumePendingHomeDeepLink()).toBe(false); + }); + + it('should publish a cross-tab cancellation token for unauthenticated internal routes', () => { + updatePendingConciergeDeepLinkForRoute(`${ROUTES.REPORT}/123`, false); + + expect(window.localStorage.getItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY)).toEqual(expect.any(String)); + }); + + it('should publish a cross-tab cancellation token for authenticated internal routes before onboarding finishes', () => { + updatePendingConciergeDeepLinkForRoute(`${ROUTES.REPORT}/123`, true); + + expect(window.localStorage.getItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY)).toEqual(expect.any(String)); + }); + + it('should preserve pending Concierge intent when authenticated Concierge is reprocessed', () => { + setPendingConciergeDeepLink(); + + updatePendingConciergeDeepLinkForRoute(ROUTES.CONCIERGE, true); + + expect(consumePendingConciergeDeepLink()).toBe(true); + expect(consumePendingHomeDeepLink()).toBe(false); + }); + it('should preserve a pending Concierge intent for generated Home routes', () => { setPendingConciergeDeepLink(); @@ -366,6 +407,28 @@ describe('navigateAfterOnboarding', () => { expect(openSidePanel).not.toHaveBeenCalled(); }); + it('should block stale Concierge intent in another tab after an explicit non-Concierge deep link', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + const openSidePanel = jest.mocked(SidePanelActions.openSidePanel); + setPendingConciergeDeepLink(); + + window.localStorage.setItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, 'non-concierge-opened-in-another-tab'); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE); + + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(openSidePanel).not.toHaveBeenCalled(); + }); + + it('should allow a new Concierge intent after an older cross-tab Home cancellation', () => { + window.localStorage.setItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, 'older-cancel'); + + setPendingConciergeDeepLink(); + + expect(consumePendingConciergeDeepLink()).toBe(true); + expect(consumePendingHomeDeepLink()).toBe(false); + }); + it('should block the Concierge RHP variant after an explicit Home deep link', async () => { const navigate = jest.spyOn(Navigation, 'navigate'); const openSidePanel = jest.mocked(SidePanelActions.openSidePanel); @@ -427,14 +490,15 @@ describe('navigateAfterOnboarding', () => { } }); - it('should let the normal onboarding destination win after root clears a stale pending Concierge deep link', () => { + it('should let an explicit root route win after clearing a stale pending Concierge deep link', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); }); }); From ee4b83bf14fc12a25bae720f603b0529d78f16d5 Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 27 Jul 2026 20:07:34 +0430 Subject: [PATCH 10/33] Fix onboarding navigation tests after merge updates --- tests/unit/navigateAfterOnboardingTest.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 1ca4a333bf8f..2dec369e0f92 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -274,7 +274,7 @@ describe('navigateAfterOnboarding', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); @@ -400,7 +400,7 @@ describe('navigateAfterOnboarding', () => { setPendingConciergeDeepLink(); updatePendingConciergeDeepLinkForRoute('', false); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); @@ -413,7 +413,7 @@ describe('navigateAfterOnboarding', () => { setPendingConciergeDeepLink(); window.localStorage.setItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, 'non-concierge-opened-in-another-tab'); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); @@ -437,7 +437,7 @@ describe('navigateAfterOnboarding', () => { await waitForBatchedUpdates(); updatePendingConciergeDeepLinkForRoute('', false); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, CONST.ONBOARDING_RHP_VARIANT.RHP_CONCIERGE_DM); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.RHP_CONCIERGE_DM}); expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); expect(navigate).not.toHaveBeenCalledWith(ROUTES.WORKSPACE_OVERVIEW.getRoute(ONBOARDING_POLICY_ID)); From 9dfe564ebe4ede4064a0279114db43640cd39446 Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 27 Jul 2026 20:58:59 +0430 Subject: [PATCH 11/33] Fix onboarding navigation test after merge updates --- tests/unit/navigateAfterOnboardingTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 2dec369e0f92..2f7cc9e16d21 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -298,7 +298,7 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); expect(navigate).toHaveBeenNthCalledWith(1, ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME); + expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME, undefined); }); it('should preserve the pending Concierge deep link across a module reload', () => { From c1d29335ee322318c31b2dc2b8a08f504e93f83d Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 30 Jul 2026 17:24:01 +0430 Subject: [PATCH 12/33] Fix Concierge deep link before onboarding modal unmounts --- src/hooks/useAutoCreateSubmitWorkspace.ts | 14 +++++-- src/hooks/useAutoCreateTrackWorkspace.ts | 10 ++++- src/hooks/useCompleteOnboarding.ts | 11 ++++- src/libs/actions/Report/index.ts | 4 ++ src/libs/navigateAfterOnboarding.ts | 42 ++++++++++++++----- .../BaseOnboardingPersonalDetails.tsx | 11 ++++- .../BaseOnboardingPurpose.tsx | 10 ++++- .../BaseOnboardingWorkspaces.tsx | 10 ++++- .../useAutoCreateSubmitWorkspace.test.ts | 8 ++-- .../unit/libs/navigateAfterOnboarding.test.ts | 14 ++++++- tests/unit/navigateAfterOnboardingTest.ts | 13 +++++- 11 files changed, 122 insertions(+), 25 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index c5703e299cea..9dd59e1e1b37 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -1,5 +1,5 @@ import Log from '@libs/Log'; -import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {canEditWorkspaceSettings, isGroupPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; @@ -88,6 +88,7 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; + let didNavigateToPendingDeepLink = false; if (shouldCompleteOnboarding) { try { @@ -101,6 +102,9 @@ function useAutoCreateSubmitWorkspace() { introSelected, isSelfTourViewed, conciergeChat, + onBeforeOnboardingModalUnmount: () => { + didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + }, }); } catch (error) { // Swallow onboarding completion failures so a network error doesn't block workspace @@ -122,8 +126,12 @@ function useAutoCreateSubmitWorkspace() { policyIDForNavigation = existingSubmitPolicyID; } - // Pass conciergeReportID so the Submit workspace completion path can honor a pending /concierge intent after refresh. - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID); + if (didNavigateToPendingDeepLink) { + return; + } + + // Pass conciergeReportID so true onboarding completion can honor a pending /concierge intent after refresh. + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID, shouldCompleteOnboarding); }, [ currentUserEmail, diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 1f139d0b823c..511355c73979 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -1,7 +1,7 @@ import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -102,6 +102,7 @@ function useAutoCreateTrackWorkspace() { // On mobile, hardcode trackExpensesWithConcierge since the web flow already works // with the CompleteGuidedSetup response and side panel isn't supported on native. let rhpVariant: OnboardingRHPVariant | undefined = isSidePanelReportSupported ? undefined : CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE; + let didNavigateToPendingDeepLink = false; try { const response = await completeOnboarding({ engagementChoice, @@ -116,6 +117,9 @@ function useAutoCreateTrackWorkspace() { isSelfTourViewed, conciergeChat, selfDMReport, + onBeforeOnboardingModalUnmount: () => { + didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); + }, }); if (isSidePanelReportSupported) { @@ -133,6 +137,10 @@ function useAutoCreateTrackWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); + if (didNavigateToPendingDeepLink) { + return; + } + navigateAfterOnboardingWithMicrotaskQueue( shouldUseNarrowLayout, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 6addbe7952fd..46b4cbe2d8fe 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -5,7 +5,7 @@ import {completeOnboarding, extractRHPVariantFromResponse} from '@libs/actions/R import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@libs/actions/Welcome'; import type {OnboardingFeatureMapItem} from '@libs/actions/Welcome/OnboardingFeatures'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -72,6 +72,7 @@ function useCompleteOnboarding() { const isAccountingEnabled = featuresMap.some((feature) => feature.id === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED && feature.enabled); const resolvedIntegration = isAccountingEnabled ? userReportedIntegration : undefined; const email = currentUserPersonalDetails.email ?? ''; + let didNavigateToPendingDeepLink = false; const {adminsChatReportID, policyID} = shouldCreateWorkspace ? createWorkspace({ @@ -118,6 +119,9 @@ function useCompleteOnboarding() { isSelfTourViewed, conciergeChat, adminsChatReport, + onBeforeOnboardingModalUnmount: () => { + didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + }, }); const rhpVariant = isSidePanelReportSupported ? extractRHPVariantFromResponse(response) : undefined; @@ -129,6 +133,11 @@ function useCompleteOnboarding() { waitForUpcomingTransition: true, }); + if (didNavigateToPendingDeepLink) { + setIsLoading(false); + return; + } + navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index d4b43bfde706..03e1b294a356 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5602,6 +5602,7 @@ type CompleteOnboardingProps = { adminsChatReport?: OnyxEntry; /** The self-DM report, looked up by ONYXKEYS.SELF_DM_REPORT_ID. */ selfDMReport?: OnyxEntry; + onBeforeOnboardingModalUnmount?: () => void; }; async function completeOnboarding({ @@ -5625,6 +5626,7 @@ async function completeOnboarding({ conciergeChat, adminsChatReport, selfDMReport, + onBeforeOnboardingModalUnmount, }: CompleteOnboardingProps) { const onboardingData = prepareOnboardingOnyxData({ introSelected, @@ -5673,6 +5675,7 @@ async function completeOnboarding({ // during the wait. Must run before the API call so useLinking processes each step // pop before the optimistic data unmounts the modal. resetOnboardingStackToRoot(); + onBeforeOnboardingModalUnmount?.(); // We need to access the nvp_onboardingRHPVariant directly from the response to redirect the user to the correct page // eslint-disable-next-line rulesdir/no-api-side-effects-method @@ -5682,6 +5685,7 @@ async function completeOnboarding({ // Pop onboarding nested stack just before the API write so useLinking removes browser // history entries for each step before the optimistic data unmounts the modal. resetOnboardingStackToRoot(); + onBeforeOnboardingModalUnmount?.(); // API calls are not chained in this case // eslint-disable-next-line rulesdir/no-multiple-api-calls diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index b02a93fd8d23..35eb16e39e51 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -33,23 +33,31 @@ type NavigateAfterOnboardingOptions = { variantOverride?: OnboardingRHPVariant | null; }; -function navigateToPendingDeepLinkAfterOnboarding(conciergeReportID?: string) { +function getPendingDeepLinkRouteAfterOnboarding(conciergeReportID?: string): Route | undefined { const shouldNavigateHomeFromDeepLink = consumePendingHomeDeepLink(); const shouldNavigateToConciergeFromDeepLink = consumePendingConciergeDeepLink(); if (shouldNavigateHomeFromDeepLink) { // The latest explicit non-Concierge route should win before onboarding variants can open Concierge, an admin room, or a workspace. - Navigation.navigate(ROUTES.HOME); - return true; + return ROUTES.HOME; } if (shouldNavigateToConciergeFromDeepLink) { // The report ID can still be unavailable immediately after signup refresh, so fall back to the Concierge route. - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); - return true; + return conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route); + } + + return undefined; +} + +function navigateToPendingDeepLinkAfterOnboarding(conciergeReportID?: string) { + const pendingDeepLinkRoute = getPendingDeepLinkRouteAfterOnboarding(conciergeReportID); + if (!pendingDeepLinkRoute) { + return false; } - return false; + Navigation.navigate(pendingDeepLinkRoute); + return true; } /** @@ -150,6 +158,12 @@ function navigateAfterOnboardingWithMicrotaskQueue( options?: NavigateAfterOnboardingOptions, ) { dismissOnboardingModalBeforeExit(); + const pendingDeepLinkRoute = getPendingDeepLinkRouteAfterOnboarding(conciergeReportID); + if (pendingDeepLinkRoute) { + Navigation.navigate(pendingDeepLinkRoute, options?.afterTransition ? {afterTransition: options.afterTransition} : undefined); + return; + } + Navigation.setNavigationActionToMicrotaskQueue(() => { navigateAfterOnboarding( isSmallScreenWidth, @@ -169,11 +183,11 @@ function navigateAfterOnboardingWithMicrotaskQueue( * navigate to Workspace > Categories with the side panel open so * the #admins room is visible in Concierge Anywhere. */ -function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string, shouldHonorPendingDeepLink = true) { setDisableDismissOnEscape(false); // Submit workspace onboarding bypasses navigateAfterOnboarding(), so honor the same pending deep-link intent here. - if (navigateToPendingDeepLinkAfterOnboarding(conciergeReportID)) { + if (shouldHonorPendingDeepLink && navigateToPendingDeepLinkAfterOnboarding(conciergeReportID)) { return; } @@ -191,11 +205,17 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } -function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string, shouldHonorPendingDeepLink = true) { dismissOnboardingModalBeforeExit(); + const pendingDeepLinkRoute = shouldHonorPendingDeepLink ? getPendingDeepLinkRouteAfterOnboarding(conciergeReportID) : undefined; + if (shouldHonorPendingDeepLink && pendingDeepLinkRoute) { + Navigation.navigate(pendingDeepLinkRoute); + return; + } + Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout, conciergeReportID); + navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout, conciergeReportID, shouldHonorPendingDeepLink); }); } -export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 2721113e3dda..0426e9e2ebfc 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -20,7 +20,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {hasURL} from '@libs/Url'; @@ -94,6 +94,7 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat } setIsLoading(true); + let didNavigateToPendingDeepLink = false; try { await completeOnboardingReport({ engagementChoice: onboardingPurposeSelected, @@ -105,11 +106,19 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat introSelected, isSelfTourViewed, conciergeChat, + onBeforeOnboardingModalUnmount: () => { + didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); + }, }); setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); + if (didNavigateToPendingDeepLink) { + setIsLoading(false); + return; + } + navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 98e485bced94..2ce572912295 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import OnboardingRefManager from '@libs/OnboardingRefManager'; import type {TOnboardingRef} from '@libs/OnboardingRefManager'; @@ -139,6 +139,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro autoCreateTrackWorkspace(personalDetailsForm.firstName, personalDetailsForm.lastName ?? '', choice); return; } + let didNavigateToPendingDeepLink = false; completeOnboarding({ engagementChoice: choice, onboardingMessage: onboardingMessages[choice], @@ -151,7 +152,14 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro isSelfTourViewed, conciergeChat, adminsChatReport, + onBeforeOnboardingModalUnmount: () => { + didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + }, }).then(() => { + if (didNavigateToPendingDeepLink) { + return; + } + navigateAfterOnboardingWithMicrotaskQueue( shouldUseNarrowLayout, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 4838eeba5c98..0a5bc248a721 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; @@ -85,6 +85,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const handleJoinWorkspace = (policy: JoinablePolicy) => { const isJoiningSubmitPolicy = policy.policyType === CONST.POLICY.TYPE.SUBMIT; const shouldUseSubmitFlow = canUseSubmit2026 && policy.automaticJoiningEnabled && isJoiningSubmitPolicy; + let didNavigateToPendingDeepLink = false; if (policy.automaticJoiningEnabled) { joinAccessiblePolicy(policy.policyID); @@ -101,10 +102,17 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding introSelected, isSelfTourViewed, conciergeChat, + onBeforeOnboardingModalUnmount: () => { + didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + }, }); setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); + if (didNavigateToPendingDeepLink) { + return; + } + if (shouldUseSubmitFlow) { // The Submit workspace path bypasses navigateAfterOnboarding(), so pass conciergeReportID for pending /concierge redirects. navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout, conciergeReportID); diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts index 31534c0d446e..cf16b0aa834e 100644 --- a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -169,7 +169,7 @@ describe('useAutoCreateSubmitWorkspace', () => { // Then the user should be navigated to the newly created Submit workspace // so they land on their workspace immediately after onboarding expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), undefined); + expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), undefined, true); }); it('passes the Concierge report ID to submit workspace navigation when available', async () => { @@ -179,7 +179,7 @@ describe('useAutoCreateSubmitWorkspace', () => { await result.current('John', 'Doe'); expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), 'concierge-report-id'); + expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), 'concierge-report-id', true); }); it('reuses the existing onboarding workspace instead of creating a new one', () => { @@ -328,7 +328,7 @@ describe('useAutoCreateSubmitWorkspace', () => { expect(createWorkspaceSpy).not.toHaveBeenCalled(); expect(completeOnboardingSpy).not.toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(existingSubmitPolicy.id, expect.any(Boolean), undefined); + expect(navigateSpy).toHaveBeenCalledWith(existingSubmitPolicy.id, expect.any(Boolean), undefined, false); }); it('keeps the Home fallback for onboarding callers when creation is skipped', async () => { @@ -363,7 +363,7 @@ describe('useAutoCreateSubmitWorkspace', () => { // behavior (landing on Home) so this fix stays scoped to already-onboarded callers expect(createWorkspaceSpy).not.toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(undefined, expect.any(Boolean), undefined); + expect(navigateSpy).toHaveBeenCalledWith(undefined, expect.any(Boolean), undefined, true); }); it('uses the localCurrencyCode from personal details for workspace currency', () => { diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index 1b5572cf385a..d9ce07bb26e9 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -1,6 +1,6 @@ import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; +import {clearPendingConciergeDeepLink, consumePendingConciergeDeepLink, setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; import ROUTES from '@src/ROUTES'; @@ -68,6 +68,18 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); expect(navigationMock.navigate).toHaveBeenCalledTimes(1); expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('concierge-report-id')); + expect(navigationMock.setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); + }); + + it('does not consume pending Concierge from the Submit welcome modal path', () => { + setPendingConciergeDeepLink(); + + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false, 'concierge-report-id', false); + + expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); + expect(consumePendingConciergeDeepLink()).toBe(true); }); it('navigates to Home before Workspace Categories when root replaced pending Concierge', () => { diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 2f7cc9e16d21..e9f493f40244 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,6 +1,6 @@ import {openReportFromDeepLink} from '@libs/actions/Link'; import SidePanelActions from '@libs/actions/SidePanel'; -import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import { clearPendingConciergeDeepLink, @@ -260,6 +260,17 @@ describe('navigateAfterOnboarding', () => { expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); + it('should navigate to pending Concierge immediately when exiting onboarding', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + const setNavigationActionToMicrotaskQueue = jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue'); + setPendingConciergeDeepLink(); + + navigateAfterOnboardingWithMicrotaskQueue(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); + expect(setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); + }); + it('should navigate to Concierge instead of the onboarding admin room when a pending Concierge deep link is available', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); From 2e8af74e261ca11bafef36bd55f0abff8f7518c4 Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 30 Jul 2026 17:53:33 +0430 Subject: [PATCH 13/33] Fix two-tab Concierge onboarding intent cancellation --- src/libs/PendingConciergeDeepLink.ts | 12 +++++++++- tests/unit/navigateAfterOnboardingTest.ts | 29 +++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 1659f020c820..307272873a48 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -150,6 +150,14 @@ function isBrowserReload() { } } +function isDocumentHidden() { + try { + return typeof document !== 'undefined' && document.hidden; + } catch { + return false; + } +} + function setPendingHomeDeepLinkIfNoPendingConcierge() { // Startup/linking can emit ambiguous root/home signals, so avoid replacing an explicit /concierge intent. if (hasPendingConciergeDeepLinkIntent()) { @@ -164,7 +172,9 @@ function setPendingHomeDeepLinkForRoot() { setPendingHomeDeepLinkIfNoPendingConcierge(); return; } - setCancelToken(); + if (!isDocumentHidden()) { + setCancelToken(); + } setPendingHomeDeepLink(); } diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index e9f493f40244..f862accd925d 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -70,6 +70,22 @@ function mockBrowserReloadNavigation(useLegacyFallback = false) { }; } +function mockDocumentHidden(isHidden: boolean) { + const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden'); + Object.defineProperty(document, 'hidden', { + configurable: true, + value: isHidden, + }); + + return () => { + if (originalHidden) { + Object.defineProperty(document, 'hidden', originalHidden); + } else { + Reflect.deleteProperty(document, 'hidden'); + } + }; +} + jest.mock('@expensify/react-native-hybrid-app', () => ({ __esModule: true, default: { @@ -366,6 +382,19 @@ describe('navigateAfterOnboarding', () => { expect(consumePendingConciergeDeepLink()).toBe(false); }); + it('should not publish a cross-tab cancellation token for background root route replays', () => { + const restoreDocumentHidden = mockDocumentHidden(true); + window.localStorage.setItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, 'existing-token'); + + try { + updatePendingConciergeDeepLinkForRoute('', true); + + expect(window.localStorage.getItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY)).toBe('existing-token'); + } finally { + restoreDocumentHidden(); + } + }); + it('should preserve pending Concierge intent when an authenticated onboarding route is replayed after refresh', () => { setPendingConciergeDeepLink(); From e2adfa85d5b745a57a26628888091a513a771e31 Mon Sep 17 00:00:00 2001 From: X Developer Date: Thu, 30 Jul 2026 18:27:38 +0430 Subject: [PATCH 14/33] Fix unsafe finally return in Track onboarding navigation --- src/hooks/useAutoCreateTrackWorkspace.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 511355c73979..07c6d57b03b3 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -137,20 +137,18 @@ function useAutoCreateTrackWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); - if (didNavigateToPendingDeepLink) { - return; + if (!didNavigateToPendingDeepLink) { + navigateAfterOnboardingWithMicrotaskQueue( + shouldUseNarrowLayout, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeChatReportID, + reportNameValuePairs, + newPolicyID, + mergedAccountConciergeReportID, + false, + {variantOverride: rhpVariant}, + ); } - - navigateAfterOnboardingWithMicrotaskQueue( - shouldUseNarrowLayout, - isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), - conciergeChatReportID, - reportNameValuePairs, - newPolicyID, - mergedAccountConciergeReportID, - false, - {variantOverride: rhpVariant}, - ); } }, [ From 033c831df10f1464381bf303e2aaa7a719dea9da Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 31 Jul 2026 02:29:05 +0430 Subject: [PATCH 15/33] fix: prevent Concierge flash after root signup onboarding --- .../SidePanel/RHPVariantTest/index.ts | 6 +- .../SidePanel/RHPVariantTest/types.ts | 7 +- src/hooks/useAutoCreateSubmitWorkspace.ts | 9 +- src/hooks/useAutoCreateTrackWorkspace.ts | 5 +- src/hooks/useCompleteOnboarding.ts | 5 +- src/libs/PendingConciergeDeepLink.ts | 45 +++++++-- src/libs/actions/Link.ts | 4 + src/libs/navigateAfterOnboarding.ts | 25 ++++- .../BaseOnboardingPersonalDetails.tsx | 15 ++- .../BaseOnboardingPurpose.tsx | 5 +- .../BaseOnboardingWorkspaces.tsx | 10 +- tests/unit/LinkTest.ts | 12 ++- .../SidePanel/RHPVariantTest.test.ts | 18 ++++ .../unit/libs/navigateAfterOnboarding.test.ts | 4 +- tests/unit/navigateAfterOnboardingTest.ts | 97 +++++++++++++++---- 15 files changed, 218 insertions(+), 49 deletions(-) diff --git a/src/components/SidePanel/RHPVariantTest/index.ts b/src/components/SidePanel/RHPVariantTest/index.ts index a859526d94c3..3b51a50ec644 100644 --- a/src/components/SidePanel/RHPVariantTest/index.ts +++ b/src/components/SidePanel/RHPVariantTest/index.ts @@ -64,10 +64,10 @@ const shouldOpenRHPVariant: ShouldOpenRHPVariant = (variantOverride) => { * All variants open the side panel without overlay. * The control variant is handled separately in navigateAfterOnboarding. */ -const handleRHPVariantNavigation: HandleRHPVariantNavigation = (onboardingPolicyID, variantOverride, navigationOptions) => { +const handleRHPVariantNavigation: HandleRHPVariantNavigation = (onboardingPolicyID, variantOverride, navigationOptions, shouldPreserveRevealedReportOverride) => { const variant = variantOverride ?? onboardingRHPVariant; if (variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { - const shouldPreserveRevealedReport = isReportTopmostSplitNavigator(); + const shouldPreserveRevealedReport = shouldPreserveRevealedReportOverride ?? isReportTopmostSplitNavigator(); if (!shouldPreserveRevealedReport) { Navigation.navigate(ROUTES.HOME, navigationOptions); } @@ -78,7 +78,7 @@ const handleRHPVariantNavigation: HandleRHPVariantNavigation = (onboardingPolicy const isRHPHomePage = variant === CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE; if (isRHPHomePage) { - const shouldPreserveRevealedReport = isReportTopmostSplitNavigator(); + const shouldPreserveRevealedReport = shouldPreserveRevealedReportOverride ?? isReportTopmostSplitNavigator(); if (!shouldPreserveRevealedReport) { Navigation.navigate(ROUTES.HOME, navigationOptions); } diff --git a/src/components/SidePanel/RHPVariantTest/types.ts b/src/components/SidePanel/RHPVariantTest/types.ts index f784b356f025..9e1eef507f65 100644 --- a/src/components/SidePanel/RHPVariantTest/types.ts +++ b/src/components/SidePanel/RHPVariantTest/types.ts @@ -3,6 +3,11 @@ import type {LinkToOptions} from '@libs/Navigation/helpers/linkTo/types'; import type {OnboardingRHPVariant} from '@src/types/onyx'; type ShouldOpenRHPVariant = (variantOverride?: OnboardingRHPVariant | null) => boolean; -type HandleRHPVariantNavigation = (onboardingPolicyID: string | undefined, variantOverride?: OnboardingRHPVariant | null, navigationOptions?: LinkToOptions) => void; +type HandleRHPVariantNavigation = ( + onboardingPolicyID: string | undefined, + variantOverride?: OnboardingRHPVariant | null, + navigationOptions?: LinkToOptions, + shouldPreserveRevealedReportOverride?: boolean, +) => void; export type {ShouldOpenRHPVariant, HandleRHPVariantNavigation}; diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 9dd59e1e1b37..22ce79c5316b 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -1,5 +1,9 @@ import Log from '@libs/Log'; -import {navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import { + navigateToPendingDeepLinkAfterOnboarding, + navigateToRootRouteBeforeOnboardingUnmount, + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue, +} from '@libs/navigateAfterOnboarding'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {canEditWorkspaceSettings, isGroupPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; @@ -104,6 +108,9 @@ function useAutoCreateSubmitWorkspace() { conciergeChat, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + if (!didNavigateToPendingDeepLink) { + navigateToRootRouteBeforeOnboardingUnmount(); + } }, }); } catch (error) { diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 07c6d57b03b3..adf38be2eb77 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -1,7 +1,7 @@ import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -119,6 +119,9 @@ function useAutoCreateTrackWorkspace() { selfDMReport, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); + if (!didNavigateToPendingDeepLink) { + navigateToRootRouteBeforeOnboardingUnmount(); + } }, }); diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 46b4cbe2d8fe..deffcc9b6619 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -5,7 +5,7 @@ import {completeOnboarding, extractRHPVariantFromResponse} from '@libs/actions/R import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@libs/actions/Welcome'; import type {OnboardingFeatureMapItem} from '@libs/actions/Welcome/OnboardingFeatures'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -121,6 +121,9 @@ function useCompleteOnboarding() { adminsChatReport, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + if (!didNavigateToPendingDeepLink) { + navigateToRootRouteBeforeOnboardingUnmount(); + } }, }); const rhpVariant = isSidePanelReportSupported ? extractRHPVariantFromResponse(response) : undefined; diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 307272873a48..6b6614409a90 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -13,11 +13,13 @@ const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET'; const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN'; const PENDING_HOME_DEEP_LINK_STORAGE_KEY = 'PENDING_HOME_DEEP_LINK'; +const ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK'; const LEGACY_PERFORMANCE_NAVIGATION_KEY = 'navigation'; const LEGACY_PERFORMANCE_NAVIGATION_TYPE_KEY = 'type'; const LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD = 1; let hasPendingConciergeDeepLink = false; let hasPendingHomeDeepLink = false; +let didRootClearPendingConciergeDeepLink = false; let pendingConciergeCancelTokenAtSet = ''; function getSessionStorage() { @@ -116,17 +118,24 @@ function clearPendingHomeDeepLink() { clearStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); } -function clearPendingConciergeDeepLink() { +function setRootClearedPendingConciergeDeepLink() { + didRootClearPendingConciergeDeepLink = true; + setStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); +} + +function clearRootClearedPendingConciergeDeepLink() { + didRootClearPendingConciergeDeepLink = false; + clearStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); +} + +function clearPendingConciergeDeepLink(shouldPreserveRootClearedPendingConciergeDeepLink = false) { hasPendingConciergeDeepLink = false; clearPendingHomeDeepLink(); clearStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); clearPendingConciergeCancelTokenAtSet(); -} - -function setPendingHomeDeepLink() { - clearPendingConciergeDeepLink(); - hasPendingHomeDeepLink = true; - setStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); + if (!shouldPreserveRootClearedPendingConciergeDeepLink) { + clearRootClearedPendingConciergeDeepLink(); + } } function isRecord(value: unknown): value is Record { @@ -163,7 +172,7 @@ function setPendingHomeDeepLinkIfNoPendingConcierge() { if (hasPendingConciergeDeepLinkIntent()) { return; } - setPendingHomeDeepLink(); + clearPendingHomeDeepLink(); } function setPendingHomeDeepLinkForRoot() { @@ -172,13 +181,20 @@ function setPendingHomeDeepLinkForRoot() { setPendingHomeDeepLinkIfNoPendingConcierge(); return; } + if (!hasPendingConciergeDeepLinkFlag()) { + clearPendingHomeDeepLink(); + setRootClearedPendingConciergeDeepLink(); + return; + } if (!isDocumentHidden()) { setCancelToken(); } - setPendingHomeDeepLink(); + clearPendingConciergeDeepLink(true); + setRootClearedPendingConciergeDeepLink(); } function setPendingConciergeDeepLink() { + clearRootClearedPendingConciergeDeepLink(); clearPendingHomeDeepLink(); hasPendingConciergeDeepLink = true; setStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); @@ -244,10 +260,18 @@ function consumePendingHomeDeepLink() { function consumePendingConciergeDeepLink() { const shouldNavigateToConcierge = hasPendingConciergeDeepLinkIntent(); - clearPendingConciergeDeepLink(); + const shouldPreserveRootClearedPendingConciergeDeepLink = + !shouldNavigateToConcierge && (didRootClearPendingConciergeDeepLink || hasStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY)); + clearPendingConciergeDeepLink(shouldPreserveRootClearedPendingConciergeDeepLink); return shouldNavigateToConcierge; } +function consumeRootClearedPendingConciergeDeepLink() { + const shouldUseStandardOnboardingRoute = didRootClearPendingConciergeDeepLink || hasStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); + clearRootClearedPendingConciergeDeepLink(); + return shouldUseStandardOnboardingRoute; +} + export { setPendingConciergeDeepLink, setPendingHomeDeepLinkForRoot, @@ -255,5 +279,6 @@ export { updatePendingConciergeDeepLinkForRoute, consumePendingConciergeDeepLink, consumePendingHomeDeepLink, + consumeRootClearedPendingConciergeDeepLink, clearPendingConciergeDeepLink, }; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 2cef8628a920..4b4be6345a04 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -499,6 +499,10 @@ function openReportFromDeepLink( updatePendingConciergeDeepLinkForRoute(route, isAuthenticated); + if (!isAuthenticated && normalizePath(route) === normalizePath(ROUTES.ROOT)) { + return; + } + // If we are not authenticated and are navigating to a public screen, we don't want to navigate again to the screen after sign-in/sign-up if (!isAuthenticated && isPublicScreenRoute(route)) { return; diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 35eb16e39e51..085ffd03dc6a 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -17,7 +17,7 @@ import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostS import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; -import {consumePendingConciergeDeepLink, consumePendingHomeDeepLink} from './PendingConciergeDeepLink'; +import {consumePendingConciergeDeepLink, consumePendingHomeDeepLink, consumeRootClearedPendingConciergeDeepLink} from './PendingConciergeDeepLink'; import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; let onboardingRHPVariant: OnyxEntry; @@ -60,6 +60,15 @@ function navigateToPendingDeepLinkAfterOnboarding(conciergeReportID?: string) { return true; } +function navigateToRootRouteBeforeOnboardingUnmount() { + if (!consumeRootClearedPendingConciergeDeepLink()) { + return false; + } + + Navigation.navigate(ROUTES.HOME); + return true; +} + /** * Determines the report ID to navigate to after onboarding for control variant or ineligible users. * On large screens, navigates to the admins chat if available. On small screens, finds the last @@ -113,6 +122,8 @@ function navigateAfterOnboarding( return; } + const shouldUseStandardRouteAfterRootClearedConcierge = consumeRootClearedPendingConciergeDeepLink(); + // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native @@ -126,7 +137,7 @@ function navigateAfterOnboarding( } if (shouldOpenRHPVariant(variantOverride)) { - handleRHPVariantNavigation(onboardingPolicyID, variantOverride, navigationOptions); + handleRHPVariantNavigation(onboardingPolicyID, variantOverride, navigationOptions, shouldUseStandardRouteAfterRootClearedConcierge ? false : undefined); return; } @@ -141,7 +152,7 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID), navigationOptions); - } else if (!isReportTopmostSplitNavigator()) { + } else if (shouldUseStandardRouteAfterRootClearedConcierge || !isReportTopmostSplitNavigator()) { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME, navigationOptions); } @@ -218,4 +229,10 @@ function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: s }); } -export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; +export { + navigateAfterOnboarding, + navigateAfterOnboardingWithMicrotaskQueue, + navigateToPendingDeepLinkAfterOnboarding, + navigateToRootRouteBeforeOnboardingUnmount, + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue, +}; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 0426e9e2ebfc..edc0bd65c314 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -3,6 +3,7 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; @@ -20,7 +21,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {hasURL} from '@libs/Url'; @@ -29,7 +30,7 @@ import {doesContainReservedWord, isValidDisplayName} from '@libs/ValidationUtils import {clearPersonalDetailsDraft, setPersonalDetails} from '@userActions/Onboarding'; import {setDisplayName, updateDisplayName} from '@userActions/PersonalDetails'; -import {completeOnboarding as completeOnboardingReport} from '@userActions/Report'; +import {completeOnboarding as completeOnboardingReport, extractRHPVariantFromResponse} from '@userActions/Report'; import {setOnboardingAdminsChatReportID, setOnboardingErrorMessage, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; @@ -96,20 +97,25 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat setIsLoading(true); let didNavigateToPendingDeepLink = false; try { - await completeOnboardingReport({ + const response = await completeOnboardingReport({ engagementChoice: onboardingPurposeSelected, onboardingMessage: onboardingMessages[onboardingPurposeSelected], firstName, lastName, adminsChatReportID: onboardingAdminsChatReportID, onboardingPolicyID, + shouldWaitForRHPVariantInitialization: isSidePanelReportSupported, introSelected, isSelfTourViewed, conciergeChat, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); + if (!didNavigateToPendingDeepLink) { + navigateToRootRouteBeforeOnboardingUnmount(); + } }, }); + const rhpVariant = isSidePanelReportSupported ? extractRHPVariantFromResponse(response) : undefined; setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); @@ -127,6 +133,9 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat onboardingPolicyID, mergedAccountConciergeReportID, false, + { + variantOverride: rhpVariant, + }, ); setIsLoading(false); } catch (error) { diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 2ce572912295..2e4bac142cf0 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import OnboardingRefManager from '@libs/OnboardingRefManager'; import type {TOnboardingRef} from '@libs/OnboardingRefManager'; @@ -154,6 +154,9 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro adminsChatReport, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + if (!didNavigateToPendingDeepLink) { + navigateToRootRouteBeforeOnboardingUnmount(); + } }, }).then(() => { if (didNavigateToPendingDeepLink) { diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 0a5bc248a721..cae7c83f8c4f 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -17,7 +17,12 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import { + navigateAfterOnboardingWithMicrotaskQueue, + navigateToPendingDeepLinkAfterOnboarding, + navigateToRootRouteBeforeOnboardingUnmount, + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue, +} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; @@ -104,6 +109,9 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding conciergeChat, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); + if (!didNavigateToPendingDeepLink) { + navigateToRootRouteBeforeOnboardingUnmount(); + } }, }); setOnboardingAdminsChatReportID(); diff --git a/tests/unit/LinkTest.ts b/tests/unit/LinkTest.ts index 201ee032b9d3..8984bcd60607 100644 --- a/tests/unit/LinkTest.ts +++ b/tests/unit/LinkTest.ts @@ -1,4 +1,4 @@ -import {canAnonymousUserAccessRoute, isAnonymousUser} from '@libs/actions/Session'; +import {canAnonymousUserAccessRoute, isAnonymousUser, waitForUserSignIn} from '@libs/actions/Session'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -7,7 +7,7 @@ import * as Url from '@libs/Url'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import {openLink} from '@src/libs/actions/Link'; +import {openLink, openReportFromDeepLink} from '@src/libs/actions/Link'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -58,6 +58,7 @@ const mockedNavigation = jest.mocked(Navigation); const mockedNavigationRef = jest.mocked(navigationRef); const mockedCanAnonymousUserAccessRoute = jest.mocked(canAnonymousUserAccessRoute); const mockedIsAnonymousUser = jest.mocked(isAnonymousUser); +const mockedWaitForUserSignIn = jest.mocked(waitForUserSignIn); function buildNavigationState(key: string, routes: NavigationState['routes'], index = routes.length - 1): NavigationState { return { @@ -349,4 +350,11 @@ describe('Link.openLink', () => { ), ); }); + + it('does not queue post-signup navigation for an unauthenticated root URL', () => { + openReportFromDeepLink('https://dev.new.expensify.com:8082/', {}, false, undefined, undefined, undefined, undefined); + + expect(mockedWaitForUserSignIn).not.toHaveBeenCalled(); + expect(Navigation.navigate).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/components/SidePanel/RHPVariantTest.test.ts b/tests/unit/components/SidePanel/RHPVariantTest.test.ts index 4878523f3712..68d513ab552f 100644 --- a/tests/unit/components/SidePanel/RHPVariantTest.test.ts +++ b/tests/unit/components/SidePanel/RHPVariantTest.test.ts @@ -78,6 +78,15 @@ describe('handleRHPVariantNavigation', () => { expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); }); + it('navigates home for the rhpHomePage variant when preserving the topmost report is disabled', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + handleRHPVariantNavigation('policyID', CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE, undefined, false); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); + expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); + }); + it('preserves the topmost report for the trackExpensesWithConcierge variant and opens the side panel on top of it', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); @@ -93,4 +102,13 @@ describe('handleRHPVariantNavigation', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); }); + + it('navigates home for the trackExpensesWithConcierge variant when preserving the topmost report is disabled', () => { + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + handleRHPVariantNavigation('policyID', CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE, undefined, false); + + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); + expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); + }); }); diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index d9ce07bb26e9..64e22dffab07 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -82,7 +82,7 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(consumePendingConciergeDeepLink()).toBe(true); }); - it('navigates to Home before Workspace Categories when root replaced pending Concierge', () => { + it('navigates to Workspace Categories when root clears pending Concierge', () => { setPendingConciergeDeepLink(); updatePendingConciergeDeepLinkForRoute('', false); @@ -90,6 +90,6 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); expect(navigationMock.navigate).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); }); }); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index f862accd925d..d3279e337729 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,11 +1,12 @@ import {openReportFromDeepLink} from '@libs/actions/Link'; import SidePanelActions from '@libs/actions/SidePanel'; -import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import { clearPendingConciergeDeepLink, consumePendingConciergeDeepLink, consumePendingHomeDeepLink, + consumeRootClearedPendingConciergeDeepLink, setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge, updatePendingConciergeDeepLinkForRoute, @@ -287,6 +288,16 @@ describe('navigateAfterOnboarding', () => { expect(setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); }); + it('should move the hidden root signup background to Home before onboarding unmounts', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + + updatePendingConciergeDeepLinkForRoute('', false); + + expect(navigateToRootRouteBeforeOnboardingUnmount()).toBe(true); + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(consumeRootClearedPendingConciergeDeepLink()).toBe(false); + }); + it('should navigate to Concierge instead of the onboarding admin room when a pending Concierge deep link is available', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); @@ -364,22 +375,62 @@ describe('navigateAfterOnboarding', () => { expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); - it('should treat an unauthenticated root route as an explicit Home intent', () => { + it('should clear pending Concierge without forcing Home for an unauthenticated root route', () => { setPendingConciergeDeepLink(); updatePendingConciergeDeepLinkForRoute('', false); - expect(consumePendingHomeDeepLink()).toBe(true); + expect(consumePendingHomeDeepLink()).toBe(false); expect(consumePendingConciergeDeepLink()).toBe(false); + expect(consumeRootClearedPendingConciergeDeepLink()).toBe(true); }); - it('should treat an authenticated root route before onboarding finishes as an explicit Home intent', () => { + it('should clear pending Concierge without forcing Home for an authenticated root route before onboarding finishes', () => { setPendingConciergeDeepLink(); updatePendingConciergeDeepLinkForRoute('', true); - expect(consumePendingHomeDeepLink()).toBe(true); + expect(consumePendingHomeDeepLink()).toBe(false); expect(consumePendingConciergeDeepLink()).toBe(false); + expect(consumeRootClearedPendingConciergeDeepLink()).toBe(true); + }); + + it('should not force Home after onboarding for a normal root signup without a pending Concierge intent', () => { + updatePendingConciergeDeepLinkForRoute('', false); + + expect(consumePendingHomeDeepLink()).toBe(false); + expect(consumePendingConciergeDeepLink()).toBe(false); + expect(consumeRootClearedPendingConciergeDeepLink()).toBe(true); + }); + + it('should clear stale Home intent so a normal root signup can use standard onboarding navigation', () => { + window.sessionStorage.setItem('PENDING_HOME_DEEP_LINK', 'true'); + + updatePendingConciergeDeepLinkForRoute('', false); + + expect(consumePendingHomeDeepLink()).toBe(false); + expect(consumePendingConciergeDeepLink()).toBe(false); + }); + + it('should use standard RHP variant routing after an explicit root route without a pending Concierge intent', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + + updatePendingConciergeDeepLinkForRoute('', false); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); + + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + }); + + it('should use standard fallback routing after an explicit root route without an RHP variant', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + updatePendingConciergeDeepLinkForRoute('', false); + navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); + + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); it('should not publish a cross-tab cancellation token for background root route replays', () => { @@ -434,17 +485,28 @@ describe('navigateAfterOnboarding', () => { expect(consumePendingHomeDeepLink()).toBe(false); }); - it('should block the track expenses Concierge variant after an explicit Home deep link', () => { + it('should use standard RHP variant routing after an explicit root route clears pending Concierge', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - const openSidePanel = jest.mocked(SidePanelActions.openSidePanel); setPendingConciergeDeepLink(); updatePendingConciergeDeepLinkForRoute('', false); navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + }); + + it('should use standard home RHP routing after an explicit root route clears pending Concierge', async () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + setPendingConciergeDeepLink(); + await Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, CONST.ONBOARDING_COMPANY_SIZE.MICRO); + await waitForBatchedUpdates(); + + updatePendingConciergeDeepLinkForRoute('', false); + navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE}); + + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(openSidePanel).not.toHaveBeenCalled(); }); it('should block stale Concierge intent in another tab after an explicit non-Concierge deep link', () => { @@ -469,9 +531,8 @@ describe('navigateAfterOnboarding', () => { expect(consumePendingHomeDeepLink()).toBe(false); }); - it('should block the Concierge RHP variant after an explicit Home deep link', async () => { + it('should not navigate to Concierge after an explicit root route clears pending Concierge', async () => { const navigate = jest.spyOn(Navigation, 'navigate'); - const openSidePanel = jest.mocked(SidePanelActions.openSidePanel); setPendingConciergeDeepLink(); await Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, CONST.ONBOARDING_COMPANY_SIZE.MICRO); await waitForBatchedUpdates(); @@ -479,12 +540,10 @@ describe('navigateAfterOnboarding', () => { updatePendingConciergeDeepLinkForRoute('', false); navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.RHP_CONCIERGE_DM}); - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.WORKSPACE_OVERVIEW.getRoute(ONBOARDING_POLICY_ID)); - expect(openSidePanel).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); - it('should clear a stale pending Concierge deep link when opening root before onboarding finishes', () => { + it('should clear a stale pending Concierge deep link without forcing Home when opening root before onboarding finishes', () => { const navigate = jest.spyOn(Navigation, 'navigate'); mockIsReportTopmostSplitNavigator.mockReturnValue(true); setPendingConciergeDeepLink(); @@ -492,8 +551,8 @@ describe('navigateAfterOnboarding', () => { openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); it('should preserve a pending Concierge deep link when root is replayed during a browser reload', () => { @@ -530,15 +589,15 @@ describe('navigateAfterOnboarding', () => { } }); - it('should let an explicit root route win after clearing a stale pending Concierge deep link', () => { + it('should use standard onboarding routing after root clears a stale pending Concierge deep link', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); + expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); }); From 6f7abdb2d8581cfa27e2ec47d835687f14e0657b Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 31 Jul 2026 03:08:59 +0430 Subject: [PATCH 16/33] fix: reset Escape dismiss state before onboarding deep-link navigation --- src/libs/navigateAfterOnboarding.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 085ffd03dc6a..605262ea110b 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -56,6 +56,7 @@ function navigateToPendingDeepLinkAfterOnboarding(conciergeReportID?: string) { return false; } + setDisableDismissOnEscape(false); Navigation.navigate(pendingDeepLinkRoute); return true; } @@ -171,6 +172,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( dismissOnboardingModalBeforeExit(); const pendingDeepLinkRoute = getPendingDeepLinkRouteAfterOnboarding(conciergeReportID); if (pendingDeepLinkRoute) { + setDisableDismissOnEscape(false); Navigation.navigate(pendingDeepLinkRoute, options?.afterTransition ? {afterTransition: options.afterTransition} : undefined); return; } @@ -220,6 +222,7 @@ function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: s dismissOnboardingModalBeforeExit(); const pendingDeepLinkRoute = shouldHonorPendingDeepLink ? getPendingDeepLinkRouteAfterOnboarding(conciergeReportID) : undefined; if (shouldHonorPendingDeepLink && pendingDeepLinkRoute) { + setDisableDismissOnEscape(false); Navigation.navigate(pendingDeepLinkRoute); return; } From 4701899f85ee87a739d9c3c67450a4936d055d82 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 1 Aug 2026 11:26:57 +0430 Subject: [PATCH 17/33] fix: keep Concierge deep link focused through onboarding --- .../SidePanel/RHPVariantTest/index.ts | 6 +- .../SidePanel/RHPVariantTest/types.ts | 7 +- src/hooks/useAutoCreateSubmitWorkspace.ts | 9 +- src/hooks/useAutoCreateTrackWorkspace.ts | 29 ++- src/hooks/useCompleteOnboarding.ts | 5 +- src/libs/PendingConciergeDeepLink.ts | 190 ++------------ src/libs/actions/Link.ts | 4 - src/libs/navigateAfterOnboarding.ts | 31 +-- .../BaseOnboardingPersonalDetails.tsx | 15 +- .../BaseOnboardingPurpose.tsx | 5 +- .../BaseOnboardingWorkspaces.tsx | 10 +- tests/unit/LinkTest.ts | 12 +- .../SidePanel/RHPVariantTest.test.ts | 18 -- .../unit/libs/navigateAfterOnboarding.test.ts | 13 +- tests/unit/navigateAfterOnboardingTest.ts | 237 +----------------- 15 files changed, 59 insertions(+), 532 deletions(-) diff --git a/src/components/SidePanel/RHPVariantTest/index.ts b/src/components/SidePanel/RHPVariantTest/index.ts index 3b51a50ec644..a859526d94c3 100644 --- a/src/components/SidePanel/RHPVariantTest/index.ts +++ b/src/components/SidePanel/RHPVariantTest/index.ts @@ -64,10 +64,10 @@ const shouldOpenRHPVariant: ShouldOpenRHPVariant = (variantOverride) => { * All variants open the side panel without overlay. * The control variant is handled separately in navigateAfterOnboarding. */ -const handleRHPVariantNavigation: HandleRHPVariantNavigation = (onboardingPolicyID, variantOverride, navigationOptions, shouldPreserveRevealedReportOverride) => { +const handleRHPVariantNavigation: HandleRHPVariantNavigation = (onboardingPolicyID, variantOverride, navigationOptions) => { const variant = variantOverride ?? onboardingRHPVariant; if (variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { - const shouldPreserveRevealedReport = shouldPreserveRevealedReportOverride ?? isReportTopmostSplitNavigator(); + const shouldPreserveRevealedReport = isReportTopmostSplitNavigator(); if (!shouldPreserveRevealedReport) { Navigation.navigate(ROUTES.HOME, navigationOptions); } @@ -78,7 +78,7 @@ const handleRHPVariantNavigation: HandleRHPVariantNavigation = (onboardingPolicy const isRHPHomePage = variant === CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE; if (isRHPHomePage) { - const shouldPreserveRevealedReport = shouldPreserveRevealedReportOverride ?? isReportTopmostSplitNavigator(); + const shouldPreserveRevealedReport = isReportTopmostSplitNavigator(); if (!shouldPreserveRevealedReport) { Navigation.navigate(ROUTES.HOME, navigationOptions); } diff --git a/src/components/SidePanel/RHPVariantTest/types.ts b/src/components/SidePanel/RHPVariantTest/types.ts index 9e1eef507f65..f784b356f025 100644 --- a/src/components/SidePanel/RHPVariantTest/types.ts +++ b/src/components/SidePanel/RHPVariantTest/types.ts @@ -3,11 +3,6 @@ import type {LinkToOptions} from '@libs/Navigation/helpers/linkTo/types'; import type {OnboardingRHPVariant} from '@src/types/onyx'; type ShouldOpenRHPVariant = (variantOverride?: OnboardingRHPVariant | null) => boolean; -type HandleRHPVariantNavigation = ( - onboardingPolicyID: string | undefined, - variantOverride?: OnboardingRHPVariant | null, - navigationOptions?: LinkToOptions, - shouldPreserveRevealedReportOverride?: boolean, -) => void; +type HandleRHPVariantNavigation = (onboardingPolicyID: string | undefined, variantOverride?: OnboardingRHPVariant | null, navigationOptions?: LinkToOptions) => void; export type {ShouldOpenRHPVariant, HandleRHPVariantNavigation}; diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 22ce79c5316b..9dd59e1e1b37 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -1,9 +1,5 @@ import Log from '@libs/Log'; -import { - navigateToPendingDeepLinkAfterOnboarding, - navigateToRootRouteBeforeOnboardingUnmount, - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue, -} from '@libs/navigateAfterOnboarding'; +import {navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {canEditWorkspaceSettings, isGroupPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; @@ -108,9 +104,6 @@ function useAutoCreateSubmitWorkspace() { conciergeChat, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - if (!didNavigateToPendingDeepLink) { - navigateToRootRouteBeforeOnboardingUnmount(); - } }, }); } catch (error) { diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index adf38be2eb77..511355c73979 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -1,7 +1,7 @@ import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -119,9 +119,6 @@ function useAutoCreateTrackWorkspace() { selfDMReport, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); - if (!didNavigateToPendingDeepLink) { - navigateToRootRouteBeforeOnboardingUnmount(); - } }, }); @@ -140,18 +137,20 @@ function useAutoCreateTrackWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); - if (!didNavigateToPendingDeepLink) { - navigateAfterOnboardingWithMicrotaskQueue( - shouldUseNarrowLayout, - isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), - conciergeChatReportID, - reportNameValuePairs, - newPolicyID, - mergedAccountConciergeReportID, - false, - {variantOverride: rhpVariant}, - ); + if (didNavigateToPendingDeepLink) { + return; } + + navigateAfterOnboardingWithMicrotaskQueue( + shouldUseNarrowLayout, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeChatReportID, + reportNameValuePairs, + newPolicyID, + mergedAccountConciergeReportID, + false, + {variantOverride: rhpVariant}, + ); } }, [ diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index deffcc9b6619..46b4cbe2d8fe 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -5,7 +5,7 @@ import {completeOnboarding, extractRHPVariantFromResponse} from '@libs/actions/R import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@libs/actions/Welcome'; import type {OnboardingFeatureMapItem} from '@libs/actions/Welcome/OnboardingFeatures'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -121,9 +121,6 @@ function useCompleteOnboarding() { adminsChatReport, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - if (!didNavigateToPendingDeepLink) { - navigateToRootRouteBeforeOnboardingUnmount(); - } }, }); const rhpVariant = isSidePanelReportSupported ? extractRHPVariantFromResponse(response) : undefined; diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 6b6614409a90..c150e9ef57e9 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -6,21 +6,13 @@ import normalizePath from './Navigation/helpers/normalizePath'; /** * Tracks whether a logged-out user opened a /concierge deep link so the app can * route them to Concierge after sign-up/onboarding. sessionStorage keeps the - * tab-scoped intent across page reloads, while localStorage lets any explicit - * non-Concierge deep link in another tab cancel older Concierge intents for the same browser. + * tab-scoped intent across page reloads while sign-out/consume paths clear it. */ const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; -const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET'; -const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN'; -const PENDING_HOME_DEEP_LINK_STORAGE_KEY = 'PENDING_HOME_DEEP_LINK'; -const ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK'; const LEGACY_PERFORMANCE_NAVIGATION_KEY = 'navigation'; const LEGACY_PERFORMANCE_NAVIGATION_TYPE_KEY = 'type'; const LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD = 1; let hasPendingConciergeDeepLink = false; -let hasPendingHomeDeepLink = false; -let didRootClearPendingConciergeDeepLink = false; -let pendingConciergeCancelTokenAtSet = ''; function getSessionStorage() { try { @@ -30,14 +22,6 @@ function getSessionStorage() { } } -function getLocalStorage() { - try { - return typeof window === 'undefined' ? undefined : window.localStorage; - } catch { - return undefined; - } -} - function getStoredValue(key: string, getStorage: () => Storage | undefined) { try { return getStorage()?.getItem(key); @@ -74,68 +58,18 @@ function clearStoredFlag(key: string) { clearStoredValue(key, getSessionStorage); } -function getCancelToken() { - return getStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, getLocalStorage) ?? ''; -} - -function setCancelToken() { - setStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, `${Date.now()}-${Math.random()}`, getLocalStorage); -} - -function setPendingConciergeCancelTokenAtSet() { - pendingConciergeCancelTokenAtSet = getCancelToken(); - setStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY, pendingConciergeCancelTokenAtSet, getSessionStorage); -} - -function clearPendingConciergeCancelTokenAtSet() { - pendingConciergeCancelTokenAtSet = ''; - clearStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY, getSessionStorage); -} - function hasPendingConciergeDeepLinkFlag() { return hasPendingConciergeDeepLink || hasStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); } -function hasCancelTokenChangedSinceConciergeWasSet() { - if (!hasPendingConciergeDeepLinkFlag()) { - return false; - } - - // A newer cancel token means another tab opened a non-Concierge route after this tab stored /concierge. - return getCancelToken() !== (getStoredValue(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_AT_SET_STORAGE_KEY, getSessionStorage) ?? pendingConciergeCancelTokenAtSet); -} - -function hasPendingConciergeDeepLinkIntent() { - return hasPendingConciergeDeepLinkFlag() && !hasCancelTokenChangedSinceConciergeWasSet(); -} - -function hasPendingHomeDeepLinkIntent() { - return hasPendingHomeDeepLink || hasStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); -} - -function clearPendingHomeDeepLink() { - hasPendingHomeDeepLink = false; - clearStoredFlag(PENDING_HOME_DEEP_LINK_STORAGE_KEY); -} - -function setRootClearedPendingConciergeDeepLink() { - didRootClearPendingConciergeDeepLink = true; - setStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); -} - -function clearRootClearedPendingConciergeDeepLink() { - didRootClearPendingConciergeDeepLink = false; - clearStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); -} - -function clearPendingConciergeDeepLink(shouldPreserveRootClearedPendingConciergeDeepLink = false) { +function clearPendingConciergeDeepLink() { hasPendingConciergeDeepLink = false; - clearPendingHomeDeepLink(); clearStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); - clearPendingConciergeCancelTokenAtSet(); - if (!shouldPreserveRootClearedPendingConciergeDeepLink) { - clearRootClearedPendingConciergeDeepLink(); - } +} + +function setPendingConciergeDeepLink() { + hasPendingConciergeDeepLink = true; + setStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); } function isRecord(value: unknown): value is Record { @@ -144,7 +78,7 @@ function isRecord(value: unknown): value is Record { function isBrowserReload() { try { - // A browser refresh during signup can replay the root route even though the stored Concierge intent is still valid. + // A browser refresh during signup can replay root/home even though the stored Concierge intent is still valid. const performance = typeof window === 'undefined' ? undefined : window.performance; const navigationEntries = performance?.getEntriesByType?.('navigation') ?? []; if (navigationEntries.some((entry) => 'type' in entry && entry.type === 'reload')) { @@ -159,54 +93,6 @@ function isBrowserReload() { } } -function isDocumentHidden() { - try { - return typeof document !== 'undefined' && document.hidden; - } catch { - return false; - } -} - -function setPendingHomeDeepLinkIfNoPendingConcierge() { - // Startup/linking can emit ambiguous root/home signals, so avoid replacing an explicit /concierge intent. - if (hasPendingConciergeDeepLinkIntent()) { - return; - } - clearPendingHomeDeepLink(); -} - -function setPendingHomeDeepLinkForRoot() { - // A non-reload root URL is the user's latest explicit intent and should cancel any pending Concierge redirect. - if (isBrowserReload()) { - setPendingHomeDeepLinkIfNoPendingConcierge(); - return; - } - if (!hasPendingConciergeDeepLinkFlag()) { - clearPendingHomeDeepLink(); - setRootClearedPendingConciergeDeepLink(); - return; - } - if (!isDocumentHidden()) { - setCancelToken(); - } - clearPendingConciergeDeepLink(true); - setRootClearedPendingConciergeDeepLink(); -} - -function setPendingConciergeDeepLink() { - clearRootClearedPendingConciergeDeepLink(); - clearPendingHomeDeepLink(); - hasPendingConciergeDeepLink = true; - setStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); - setPendingConciergeCancelTokenAtSet(); -} - -function cancelPendingConciergeDeepLinkFromExplicitRoute() { - // Share explicit non-Concierge route intent across tabs so stale /concierge signup flows are canceled everywhere. - setCancelToken(); - clearPendingConciergeDeepLink(); -} - function getNormalizedRouteWithoutParams(route: string) { const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); return routeWithoutParams.replace(/\/$/, '') || '/'; @@ -217,68 +103,42 @@ function isOnboardingRoute(normalizedRoute: string) { return normalizedRoute === normalizePath(ROUTES.ONBOARDING_ROOT.route) || normalizedRoute.startsWith(`${normalizePath(ROUTES.ONBOARDING_ROOT.route)}/`); } +function setPendingHomeDeepLinkIfNoPendingConcierge() { + // Startup/linking can emit ambiguous root/home signals, so avoid replacing an explicit /concierge intent. + if (hasPendingConciergeDeepLinkFlag()) { + return; + } + clearPendingConciergeDeepLink(); +} + // Keep pending signup deep-link intent consistent across initial URL handling and later Linking URL events. function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: boolean) { const normalizedRoute = getNormalizedRouteWithoutParams(route); const routeForPublicScreen = normalizedRoute === '/' ? '' : normalizedRoute.slice(1); + if (isAuthenticated) { - // Authenticated URL events can arrive after signup but before onboarding consumes the pending route intent. - if (normalizedRoute === '/') { - // Root can be opened after signup but before onboarding finishes, so keep it as an explicit Home intent. - setPendingHomeDeepLinkForRoot(); - } else if (isOnboardingRoute(normalizedRoute)) { - // Refreshing during onboarding should not replace the original signup deep-link intent. + if (normalizedRoute === '/' || normalizedRoute === normalizePath(ROUTES.HOME) || isOnboardingRoute(normalizedRoute)) { return; - } else if (normalizedRoute !== normalizePath(ROUTES.CONCIERGE) && normalizedRoute !== normalizePath(ROUTES.HOME) && !isPublicScreenRoute(routeForPublicScreen)) { - cancelPendingConciergeDeepLinkFromExplicitRoute(); + } + if (normalizedRoute !== normalizePath(ROUTES.CONCIERGE) && !isPublicScreenRoute(routeForPublicScreen)) { + clearPendingConciergeDeepLink(); } return; } if (normalizedRoute === normalizePath(ROUTES.CONCIERGE)) { setPendingConciergeDeepLink(); - } else if (normalizedRoute === '/') { - // Root is an explicit normal signup intent, so it cancels Concierge unless it is a reload replay. - setPendingHomeDeepLinkForRoot(); - } else if (normalizedRoute === normalizePath(ROUTES.HOME)) { - // /home can be generated during auth/startup reloads, so keep an existing Concierge intent if one is already stored. + } else if ((normalizedRoute === '/' && isBrowserReload()) || normalizedRoute === normalizePath(ROUTES.HOME)) { setPendingHomeDeepLinkIfNoPendingConcierge(); } else if (!isPublicScreenRoute(routeForPublicScreen)) { - // A different protected/internal deep link should not inherit an older Concierge redirect. - cancelPendingConciergeDeepLinkFromExplicitRoute(); - } -} - -function consumePendingHomeDeepLink() { - const shouldNavigateHome = hasPendingHomeDeepLinkIntent() || hasCancelTokenChangedSinceConciergeWasSet(); - clearPendingHomeDeepLink(); - if (shouldNavigateHome) { clearPendingConciergeDeepLink(); } - return shouldNavigateHome; } function consumePendingConciergeDeepLink() { - const shouldNavigateToConcierge = hasPendingConciergeDeepLinkIntent(); - const shouldPreserveRootClearedPendingConciergeDeepLink = - !shouldNavigateToConcierge && (didRootClearPendingConciergeDeepLink || hasStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY)); - clearPendingConciergeDeepLink(shouldPreserveRootClearedPendingConciergeDeepLink); + const shouldNavigateToConcierge = hasPendingConciergeDeepLinkFlag(); + clearPendingConciergeDeepLink(); return shouldNavigateToConcierge; } -function consumeRootClearedPendingConciergeDeepLink() { - const shouldUseStandardOnboardingRoute = didRootClearPendingConciergeDeepLink || hasStoredFlag(ROOT_CLEARED_PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); - clearRootClearedPendingConciergeDeepLink(); - return shouldUseStandardOnboardingRoute; -} - -export { - setPendingConciergeDeepLink, - setPendingHomeDeepLinkForRoot, - setPendingHomeDeepLinkIfNoPendingConcierge, - updatePendingConciergeDeepLinkForRoute, - consumePendingConciergeDeepLink, - consumePendingHomeDeepLink, - consumeRootClearedPendingConciergeDeepLink, - clearPendingConciergeDeepLink, -}; +export {setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge, updatePendingConciergeDeepLinkForRoute, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 4b4be6345a04..2cef8628a920 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -499,10 +499,6 @@ function openReportFromDeepLink( updatePendingConciergeDeepLinkForRoute(route, isAuthenticated); - if (!isAuthenticated && normalizePath(route) === normalizePath(ROUTES.ROOT)) { - return; - } - // If we are not authenticated and are navigating to a public screen, we don't want to navigate again to the screen after sign-in/sign-up if (!isAuthenticated && isPublicScreenRoute(route)) { return; diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 605262ea110b..950ecf28f960 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -17,7 +17,7 @@ import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostS import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; -import {consumePendingConciergeDeepLink, consumePendingHomeDeepLink, consumeRootClearedPendingConciergeDeepLink} from './PendingConciergeDeepLink'; +import {consumePendingConciergeDeepLink} from './PendingConciergeDeepLink'; import {findLastAccessedReport, isConciergeChatReport, isSelfDM} from './ReportUtils'; let onboardingRHPVariant: OnyxEntry; @@ -34,14 +34,8 @@ type NavigateAfterOnboardingOptions = { }; function getPendingDeepLinkRouteAfterOnboarding(conciergeReportID?: string): Route | undefined { - const shouldNavigateHomeFromDeepLink = consumePendingHomeDeepLink(); const shouldNavigateToConciergeFromDeepLink = consumePendingConciergeDeepLink(); - if (shouldNavigateHomeFromDeepLink) { - // The latest explicit non-Concierge route should win before onboarding variants can open Concierge, an admin room, or a workspace. - return ROUTES.HOME; - } - if (shouldNavigateToConciergeFromDeepLink) { // The report ID can still be unavailable immediately after signup refresh, so fall back to the Concierge route. return conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route); @@ -61,15 +55,6 @@ function navigateToPendingDeepLinkAfterOnboarding(conciergeReportID?: string) { return true; } -function navigateToRootRouteBeforeOnboardingUnmount() { - if (!consumeRootClearedPendingConciergeDeepLink()) { - return false; - } - - Navigation.navigate(ROUTES.HOME); - return true; -} - /** * Determines the report ID to navigate to after onboarding for control variant or ineligible users. * On large screens, navigates to the admins chat if available. On small screens, finds the last @@ -123,8 +108,6 @@ function navigateAfterOnboarding( return; } - const shouldUseStandardRouteAfterRootClearedConcierge = consumeRootClearedPendingConciergeDeepLink(); - // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native @@ -138,7 +121,7 @@ function navigateAfterOnboarding( } if (shouldOpenRHPVariant(variantOverride)) { - handleRHPVariantNavigation(onboardingPolicyID, variantOverride, navigationOptions, shouldUseStandardRouteAfterRootClearedConcierge ? false : undefined); + handleRHPVariantNavigation(onboardingPolicyID, variantOverride, navigationOptions); return; } @@ -153,7 +136,7 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID), navigationOptions); - } else if (shouldUseStandardRouteAfterRootClearedConcierge || !isReportTopmostSplitNavigator()) { + } else if (!isReportTopmostSplitNavigator()) { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME, navigationOptions); } @@ -232,10 +215,4 @@ function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: s }); } -export { - navigateAfterOnboarding, - navigateAfterOnboardingWithMicrotaskQueue, - navigateToPendingDeepLinkAfterOnboarding, - navigateToRootRouteBeforeOnboardingUnmount, - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue, -}; +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index edc0bd65c314..0426e9e2ebfc 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -3,7 +3,6 @@ import InputWrapper from '@components/Form/InputWrapper'; import type {FormOnyxValues} from '@components/Form/types'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; @@ -21,7 +20,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {hasURL} from '@libs/Url'; @@ -30,7 +29,7 @@ import {doesContainReservedWord, isValidDisplayName} from '@libs/ValidationUtils import {clearPersonalDetailsDraft, setPersonalDetails} from '@userActions/Onboarding'; import {setDisplayName, updateDisplayName} from '@userActions/PersonalDetails'; -import {completeOnboarding as completeOnboardingReport, extractRHPVariantFromResponse} from '@userActions/Report'; +import {completeOnboarding as completeOnboardingReport} from '@userActions/Report'; import {setOnboardingAdminsChatReportID, setOnboardingErrorMessage, setOnboardingPolicyID} from '@userActions/Welcome'; import CONST from '@src/CONST'; @@ -97,25 +96,20 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat setIsLoading(true); let didNavigateToPendingDeepLink = false; try { - const response = await completeOnboardingReport({ + await completeOnboardingReport({ engagementChoice: onboardingPurposeSelected, onboardingMessage: onboardingMessages[onboardingPurposeSelected], firstName, lastName, adminsChatReportID: onboardingAdminsChatReportID, onboardingPolicyID, - shouldWaitForRHPVariantInitialization: isSidePanelReportSupported, introSelected, isSelfTourViewed, conciergeChat, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); - if (!didNavigateToPendingDeepLink) { - navigateToRootRouteBeforeOnboardingUnmount(); - } }, }); - const rhpVariant = isSidePanelReportSupported ? extractRHPVariantFromResponse(response) : undefined; setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); @@ -133,9 +127,6 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat onboardingPolicyID, mergedAccountConciergeReportID, false, - { - variantOverride: rhpVariant, - }, ); setIsLoading(false); } catch (error) { diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 2e4bac142cf0..2ce572912295 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import OnboardingRefManager from '@libs/OnboardingRefManager'; import type {TOnboardingRef} from '@libs/OnboardingRefManager'; @@ -154,9 +154,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro adminsChatReport, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - if (!didNavigateToPendingDeepLink) { - navigateToRootRouteBeforeOnboardingUnmount(); - } }, }).then(() => { if (didNavigateToPendingDeepLink) { diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index cae7c83f8c4f..0a5bc248a721 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -17,12 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import { - navigateAfterOnboardingWithMicrotaskQueue, - navigateToPendingDeepLinkAfterOnboarding, - navigateToRootRouteBeforeOnboardingUnmount, - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue, -} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; @@ -109,9 +104,6 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding conciergeChat, onBeforeOnboardingModalUnmount: () => { didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - if (!didNavigateToPendingDeepLink) { - navigateToRootRouteBeforeOnboardingUnmount(); - } }, }); setOnboardingAdminsChatReportID(); diff --git a/tests/unit/LinkTest.ts b/tests/unit/LinkTest.ts index 8984bcd60607..201ee032b9d3 100644 --- a/tests/unit/LinkTest.ts +++ b/tests/unit/LinkTest.ts @@ -1,4 +1,4 @@ -import {canAnonymousUserAccessRoute, isAnonymousUser, waitForUserSignIn} from '@libs/actions/Session'; +import {canAnonymousUserAccessRoute, isAnonymousUser} from '@libs/actions/Session'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; @@ -7,7 +7,7 @@ import * as Url from '@libs/Url'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; -import {openLink, openReportFromDeepLink} from '@src/libs/actions/Link'; +import {openLink} from '@src/libs/actions/Link'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; @@ -58,7 +58,6 @@ const mockedNavigation = jest.mocked(Navigation); const mockedNavigationRef = jest.mocked(navigationRef); const mockedCanAnonymousUserAccessRoute = jest.mocked(canAnonymousUserAccessRoute); const mockedIsAnonymousUser = jest.mocked(isAnonymousUser); -const mockedWaitForUserSignIn = jest.mocked(waitForUserSignIn); function buildNavigationState(key: string, routes: NavigationState['routes'], index = routes.length - 1): NavigationState { return { @@ -350,11 +349,4 @@ describe('Link.openLink', () => { ), ); }); - - it('does not queue post-signup navigation for an unauthenticated root URL', () => { - openReportFromDeepLink('https://dev.new.expensify.com:8082/', {}, false, undefined, undefined, undefined, undefined); - - expect(mockedWaitForUserSignIn).not.toHaveBeenCalled(); - expect(Navigation.navigate).not.toHaveBeenCalled(); - }); }); diff --git a/tests/unit/components/SidePanel/RHPVariantTest.test.ts b/tests/unit/components/SidePanel/RHPVariantTest.test.ts index 68d513ab552f..4878523f3712 100644 --- a/tests/unit/components/SidePanel/RHPVariantTest.test.ts +++ b/tests/unit/components/SidePanel/RHPVariantTest.test.ts @@ -78,15 +78,6 @@ describe('handleRHPVariantNavigation', () => { expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); }); - it('navigates home for the rhpHomePage variant when preserving the topmost report is disabled', () => { - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - - handleRHPVariantNavigation('policyID', CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE, undefined, false); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); - expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); - }); - it('preserves the topmost report for the trackExpensesWithConcierge variant and opens the side panel on top of it', () => { mockIsReportTopmostSplitNavigator.mockReturnValue(true); @@ -102,13 +93,4 @@ describe('handleRHPVariantNavigation', () => { expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); }); - - it('navigates home for the trackExpensesWithConcierge variant when preserving the topmost report is disabled', () => { - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - - handleRHPVariantNavigation('policyID', CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE, undefined, false); - - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); - expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(true); - }); }); diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index 64e22dffab07..ee29b2d69366 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -1,6 +1,6 @@ import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import {clearPendingConciergeDeepLink, consumePendingConciergeDeepLink, setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; +import {clearPendingConciergeDeepLink, consumePendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import ROUTES from '@src/ROUTES'; @@ -81,15 +81,4 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); expect(consumePendingConciergeDeepLink()).toBe(true); }); - - it('navigates to Workspace Categories when root clears pending Concierge', () => { - setPendingConciergeDeepLink(); - updatePendingConciergeDeepLinkForRoute('', false); - - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false, 'concierge-report-id'); - - expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); - }); }); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index d3279e337729..249e735a3856 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,16 +1,7 @@ import {openReportFromDeepLink} from '@libs/actions/Link'; -import SidePanelActions from '@libs/actions/SidePanel'; -import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToRootRouteBeforeOnboardingUnmount} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import { - clearPendingConciergeDeepLink, - consumePendingConciergeDeepLink, - consumePendingHomeDeepLink, - consumeRootClearedPendingConciergeDeepLink, - setPendingConciergeDeepLink, - setPendingHomeDeepLinkIfNoPendingConcierge, - updatePendingConciergeDeepLinkForRoute, -} from '@libs/PendingConciergeDeepLink'; +import {clearPendingConciergeDeepLink, consumePendingConciergeDeepLink, setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; import type * as PendingConciergeDeepLink from '@libs/PendingConciergeDeepLink'; import type * as ReportUtils from '@libs/ReportUtils'; @@ -31,7 +22,6 @@ const ONBOARDING_ADMINS_CHAT_REPORT_ID = '1'; const ONBOARDING_POLICY_ID = '2'; const REPORT_ID = '3'; const USER_ID = '4'; -const PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN'; const mockFindLastAccessedReport = jest.fn, Parameters>(); const mockShouldOpenOnAdminRoom = jest.fn(); const mockIsReportTopmostSplitNavigator = jest.fn(() => false); @@ -71,22 +61,6 @@ function mockBrowserReloadNavigation(useLegacyFallback = false) { }; } -function mockDocumentHidden(isHidden: boolean) { - const originalHidden = Object.getOwnPropertyDescriptor(document, 'hidden'); - Object.defineProperty(document, 'hidden', { - configurable: true, - value: isHidden, - }); - - return () => { - if (originalHidden) { - Object.defineProperty(document, 'hidden', originalHidden); - } else { - Reflect.deleteProperty(document, 'hidden'); - } - }; -} - jest.mock('@expensify/react-native-hybrid-app', () => ({ __esModule: true, default: { @@ -161,7 +135,6 @@ describe('navigateAfterOnboarding', () => { beforeEach(async () => { jest.clearAllMocks(); - window.localStorage.removeItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY); clearPendingConciergeDeepLink(); mockIsReportTopmostSplitNavigator.mockReturnValue(false); return Onyx.clear(); @@ -288,16 +261,6 @@ describe('navigateAfterOnboarding', () => { expect(setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); }); - it('should move the hidden root signup background to Home before onboarding unmounts', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - - updatePendingConciergeDeepLinkForRoute('', false); - - expect(navigateToRootRouteBeforeOnboardingUnmount()).toBe(true); - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); - expect(consumeRootClearedPendingConciergeDeepLink()).toBe(false); - }); - it('should navigate to Concierge instead of the onboarding admin room when a pending Concierge deep link is available', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); @@ -363,196 +326,12 @@ describe('navigateAfterOnboarding', () => { expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); - it('should not let an ambiguous home fallback override a pending Concierge deep link', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - setPendingConciergeDeepLink(); - - setPendingHomeDeepLinkIfNoPendingConcierge(); - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); - }); - - it('should clear pending Concierge without forcing Home for an unauthenticated root route', () => { - setPendingConciergeDeepLink(); - - updatePendingConciergeDeepLinkForRoute('', false); - - expect(consumePendingHomeDeepLink()).toBe(false); - expect(consumePendingConciergeDeepLink()).toBe(false); - expect(consumeRootClearedPendingConciergeDeepLink()).toBe(true); - }); - - it('should clear pending Concierge without forcing Home for an authenticated root route before onboarding finishes', () => { - setPendingConciergeDeepLink(); - - updatePendingConciergeDeepLinkForRoute('', true); - - expect(consumePendingHomeDeepLink()).toBe(false); - expect(consumePendingConciergeDeepLink()).toBe(false); - expect(consumeRootClearedPendingConciergeDeepLink()).toBe(true); - }); - - it('should not force Home after onboarding for a normal root signup without a pending Concierge intent', () => { - updatePendingConciergeDeepLinkForRoute('', false); - - expect(consumePendingHomeDeepLink()).toBe(false); - expect(consumePendingConciergeDeepLink()).toBe(false); - expect(consumeRootClearedPendingConciergeDeepLink()).toBe(true); - }); - - it('should clear stale Home intent so a normal root signup can use standard onboarding navigation', () => { - window.sessionStorage.setItem('PENDING_HOME_DEEP_LINK', 'true'); - - updatePendingConciergeDeepLinkForRoute('', false); - - expect(consumePendingHomeDeepLink()).toBe(false); - expect(consumePendingConciergeDeepLink()).toBe(false); - }); - - it('should use standard RHP variant routing after an explicit root route without a pending Concierge intent', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - - updatePendingConciergeDeepLinkForRoute('', false); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); - - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - }); - - it('should use standard fallback routing after an explicit root route without an RHP variant', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - - updatePendingConciergeDeepLinkForRoute('', false); - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - }); - - it('should not publish a cross-tab cancellation token for background root route replays', () => { - const restoreDocumentHidden = mockDocumentHidden(true); - window.localStorage.setItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, 'existing-token'); - - try { - updatePendingConciergeDeepLinkForRoute('', true); - - expect(window.localStorage.getItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY)).toBe('existing-token'); - } finally { - restoreDocumentHidden(); - } - }); - it('should preserve pending Concierge intent when an authenticated onboarding route is replayed after refresh', () => { setPendingConciergeDeepLink(); updatePendingConciergeDeepLinkForRoute(ROUTES.ONBOARDING_PURPOSE.route, true); expect(consumePendingConciergeDeepLink()).toBe(true); - expect(consumePendingHomeDeepLink()).toBe(false); - }); - - it('should publish a cross-tab cancellation token for unauthenticated internal routes', () => { - updatePendingConciergeDeepLinkForRoute(`${ROUTES.REPORT}/123`, false); - - expect(window.localStorage.getItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY)).toEqual(expect.any(String)); - }); - - it('should publish a cross-tab cancellation token for authenticated internal routes before onboarding finishes', () => { - updatePendingConciergeDeepLinkForRoute(`${ROUTES.REPORT}/123`, true); - - expect(window.localStorage.getItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY)).toEqual(expect.any(String)); - }); - - it('should preserve pending Concierge intent when authenticated Concierge is reprocessed', () => { - setPendingConciergeDeepLink(); - - updatePendingConciergeDeepLinkForRoute(ROUTES.CONCIERGE, true); - - expect(consumePendingConciergeDeepLink()).toBe(true); - expect(consumePendingHomeDeepLink()).toBe(false); - }); - - it('should preserve a pending Concierge intent for generated Home routes', () => { - setPendingConciergeDeepLink(); - - updatePendingConciergeDeepLinkForRoute(ROUTES.HOME, false); - - expect(consumePendingConciergeDeepLink()).toBe(true); - expect(consumePendingHomeDeepLink()).toBe(false); - }); - - it('should use standard RHP variant routing after an explicit root route clears pending Concierge', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - setPendingConciergeDeepLink(); - - updatePendingConciergeDeepLinkForRoute('', false); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); - - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - }); - - it('should use standard home RHP routing after an explicit root route clears pending Concierge', async () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - setPendingConciergeDeepLink(); - await Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, CONST.ONBOARDING_COMPANY_SIZE.MICRO); - await waitForBatchedUpdates(); - - updatePendingConciergeDeepLinkForRoute('', false); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.RHP_HOME_PAGE}); - - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - }); - - it('should block stale Concierge intent in another tab after an explicit non-Concierge deep link', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - const openSidePanel = jest.mocked(SidePanelActions.openSidePanel); - setPendingConciergeDeepLink(); - - window.localStorage.setItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, 'non-concierge-opened-in-another-tab'); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); - - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(openSidePanel).not.toHaveBeenCalled(); - }); - - it('should allow a new Concierge intent after an older cross-tab Home cancellation', () => { - window.localStorage.setItem(PENDING_CONCIERGE_DEEP_LINK_CANCEL_TOKEN_STORAGE_KEY, 'older-cancel'); - - setPendingConciergeDeepLink(); - - expect(consumePendingConciergeDeepLink()).toBe(true); - expect(consumePendingHomeDeepLink()).toBe(false); - }); - - it('should not navigate to Concierge after an explicit root route clears pending Concierge', async () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - setPendingConciergeDeepLink(); - await Onyx.set(ONYXKEYS.ONBOARDING_COMPANY_SIZE, CONST.ONBOARDING_COMPANY_SIZE.MICRO); - await waitForBatchedUpdates(); - - updatePendingConciergeDeepLinkForRoute('', false); - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.RHP_CONCIERGE_DM}); - - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - }); - - it('should clear a stale pending Concierge deep link without forcing Home when opening root before onboarding finishes', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - setPendingConciergeDeepLink(); - - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); it('should preserve a pending Concierge deep link when root is replayed during a browser reload', () => { @@ -588,16 +367,4 @@ describe('navigateAfterOnboarding', () => { restoreBrowserNavigation(); } }); - - it('should use standard onboarding routing after root clears a stale pending Concierge deep link', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - setPendingConciergeDeepLink(); - - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); - }); }); From 2302dca558188ea661d26714cd39f5924226005e Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 1 Aug 2026 12:52:47 +0430 Subject: [PATCH 18/33] fix: make Track onboarding cleanup lint-safe --- src/hooks/useAutoCreateTrackWorkspace.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 511355c73979..07c6d57b03b3 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -137,20 +137,18 @@ function useAutoCreateTrackWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); - if (didNavigateToPendingDeepLink) { - return; + if (!didNavigateToPendingDeepLink) { + navigateAfterOnboardingWithMicrotaskQueue( + shouldUseNarrowLayout, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeChatReportID, + reportNameValuePairs, + newPolicyID, + mergedAccountConciergeReportID, + false, + {variantOverride: rhpVariant}, + ); } - - navigateAfterOnboardingWithMicrotaskQueue( - shouldUseNarrowLayout, - isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), - conciergeChatReportID, - reportNameValuePairs, - newPolicyID, - mergedAccountConciergeReportID, - false, - {variantOverride: rhpVariant}, - ); } }, [ From 0c58c360c5f0a61d8ef7ffd92d49c8222f7b7067 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sun, 2 Aug 2026 11:48:34 +0430 Subject: [PATCH 19/33] fix: keep Concierge deep link through onboarding refresh --- src/DeepLinkHandler.tsx | 44 ++++++++++++++++++++++++---- src/libs/PendingConciergeDeepLink.ts | 21 +++++++++++++ src/libs/actions/Link.ts | 11 +++++-- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index c8732d0e816d..4e57c3fc0e28 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -3,8 +3,6 @@ import type {NativeEventSubscription} from 'react-native'; import {useCallback, useEffect, useRef} from 'react'; import {Linking} from 'react-native'; -import type {Route} from './ROUTES'; - import CONST from './CONST'; import useIsAuthenticated from './hooks/useIsAuthenticated'; import useOnyx from './hooks/useOnyx'; @@ -12,10 +10,12 @@ import {openReportFromDeepLink} from './libs/actions/Link'; import * as Report from './libs/actions/Report'; import {hasAuthToken, isAnonymousUser} from './libs/actions/Session'; import Log from './libs/Log'; -import {setPendingHomeDeepLinkIfNoPendingConcierge} from './libs/PendingConciergeDeepLink'; +import normalizePath from './libs/Navigation/helpers/normalizePath'; +import {setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge} from './libs/PendingConciergeDeepLink'; import {getReportIDFromLink} from './libs/ReportUtils'; import {endSpan} from './libs/telemetry/activeSpans'; import ONYXKEYS from './ONYXKEYS'; +import ROUTES, {type Route} from './ROUTES'; import {hasSeenTourSelector} from './selectors/Onboarding'; import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue'; @@ -24,6 +24,36 @@ type DeepLinkHandlerProps = { onInitialUrl: (url: Route | null) => void; }; +function getNormalizedCurrentPath() { + if (typeof window === 'undefined') { + return ''; + } + + return normalizePath(window.location.pathname).replace(/\/$/, '') || '/'; +} + +function getNormalizedPathFromURL(url: string) { + let path = url; + + try { + const parsedURL = new URL(url); + path = parsedURL.protocol === 'http:' || parsedURL.protocol === 'https:' ? parsedURL.pathname : `${parsedURL.host}${parsedURL.pathname}`; + } catch { + // If URL parsing fails, treat the value as a route path. + } + + return normalizePath(path).replace(/\/$/, '') || '/'; +} + +function isCurrentPathConcierge() { + return getNormalizedCurrentPath() === normalizePath(ROUTES.CONCIERGE); +} + +function isCurrentPathRootOrOnboarding() { + const normalizedPath = getNormalizedCurrentPath(); + return normalizedPath === '/' || normalizedPath === normalizePath(ROUTES.ONBOARDING_ROOT.route) || normalizedPath.startsWith(`${normalizePath(ROUTES.ONBOARDING_ROOT.route)}/`); +} + /** * Component that does not render anything but isolates the COLLECTION.REPORT Onyx subscription * from the root Expensify component to prevent cascading re-renders of the @@ -97,6 +127,10 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { initialUrlProcessed.current = true; onInitialUrl(url as Route); + if (!isCurrentlyAuthenticated && isCurrentPathConcierge() && (!url || getNormalizedPathFromURL(url) !== normalizePath(ROUTES.CONCIERGE))) { + setPendingConciergeDeepLink(); + } + if (url) { if (conciergeReportID === undefined) { Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url}); @@ -107,8 +141,8 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { openReportFromDeepLink(url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { - if (!isCurrentlyAuthenticated && typeof window !== 'undefined' && window.location.pathname === '/') { - // A missing initial URL at root can happen during startup, so don't override a stored /concierge intent. + if (!isCurrentlyAuthenticated && isCurrentPathRootOrOnboarding()) { + // A missing initial URL at root/onboarding can happen during startup, so don't override a stored /concierge intent. setPendingHomeDeepLinkIfNoPendingConcierge(); } Report.doneCheckingPublicRoom(); diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index c150e9ef57e9..5c0074d77e87 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -93,6 +93,19 @@ function isBrowserReload() { } } +function isCurrentBrowserPathOnboarding() { + try { + if (typeof window === 'undefined') { + return false; + } + + const normalizedPath = getNormalizedRouteWithoutParams(window.location.pathname); + return isOnboardingRoute(normalizedPath); + } catch { + return false; + } +} + function getNormalizedRouteWithoutParams(route: string) { const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); return routeWithoutParams.replace(/\/$/, '') || '/'; @@ -111,11 +124,19 @@ function setPendingHomeDeepLinkIfNoPendingConcierge() { clearPendingConciergeDeepLink(); } +function isAmbiguousStartupOrOnboardingReplay(normalizedRoute: string) { + return isOnboardingRoute(normalizedRoute) || (normalizedRoute === '/' && (isBrowserReload() || isCurrentBrowserPathOnboarding())); +} + // Keep pending signup deep-link intent consistent across initial URL handling and later Linking URL events. function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: boolean) { const normalizedRoute = getNormalizedRouteWithoutParams(route); const routeForPublicScreen = normalizedRoute === '/' ? '' : normalizedRoute.slice(1); + if (hasPendingConciergeDeepLinkFlag() && isAmbiguousStartupOrOnboardingReplay(normalizedRoute)) { + return; + } + if (isAuthenticated) { if (normalizedRoute === '/' || normalizedRoute === normalizePath(ROUTES.HOME) || isOnboardingRoute(normalizedRoute)) { return; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 2cef8628a920..5e985f79d9e6 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -16,7 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import {getIsOffline} from '@libs/NetworkState'; -import {updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; +import {setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; import {findLastAccessedReport, getReportIDFromLink, getReportOrDraftReport, getRouteFromLink, isMoneyRequestReport} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -553,7 +553,14 @@ function openReportFromDeepLink( const state = navigationRef.getRootState(); const currentFocusedRoute = findFocusedRoute(state); - if (isOnboardingFlowName(currentFocusedRoute?.name)) { + const isConciergeRoute = normalizePath(route) === normalizePath(ROUTES.CONCIERGE); + const isOnboardingFlowFocused = isOnboardingFlowName(currentFocusedRoute?.name); + + if (isConciergeRoute && (initialHasCompletedGuidedSetupFlow === false || isOnboardingFlowFocused)) { + setPendingConciergeDeepLink(); + } + + if (isOnboardingFlowFocused) { setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); return; } From 0a8819552138e36bba1002dfbe9c24ad86681633 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sun, 2 Aug 2026 12:14:45 +0430 Subject: [PATCH 20/33] fix: clear Concierge intent on explicit root signup flow --- src/libs/PendingConciergeDeepLink.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 5c0074d77e87..948f5315956a 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -138,7 +138,11 @@ function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: } if (isAuthenticated) { - if (normalizedRoute === '/' || normalizedRoute === normalizePath(ROUTES.HOME) || isOnboardingRoute(normalizedRoute)) { + if (normalizedRoute === '/') { + clearPendingConciergeDeepLink(); + return; + } + if (normalizedRoute === normalizePath(ROUTES.HOME) || isOnboardingRoute(normalizedRoute)) { return; } if (normalizedRoute !== normalizePath(ROUTES.CONCIERGE) && !isPublicScreenRoute(routeForPublicScreen)) { @@ -149,7 +153,9 @@ function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: if (normalizedRoute === normalizePath(ROUTES.CONCIERGE)) { setPendingConciergeDeepLink(); - } else if ((normalizedRoute === '/' && isBrowserReload()) || normalizedRoute === normalizePath(ROUTES.HOME)) { + } else if (normalizedRoute === '/') { + clearPendingConciergeDeepLink(); + } else if (normalizedRoute === normalizePath(ROUTES.HOME)) { setPendingHomeDeepLinkIfNoPendingConcierge(); } else if (!isPublicScreenRoute(routeForPublicScreen)) { clearPendingConciergeDeepLink(); From c52872a5dccd4b7ed10d86ad8acdd17fda80a699 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sun, 2 Aug 2026 12:30:27 +0430 Subject: [PATCH 21/33] fix: separate DeepLinkHandler route type import --- src/DeepLinkHandler.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 4e57c3fc0e28..abcc10dd5249 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -3,6 +3,8 @@ import type {NativeEventSubscription} from 'react-native'; import {useCallback, useEffect, useRef} from 'react'; import {Linking} from 'react-native'; +import type {Route} from './ROUTES'; + import CONST from './CONST'; import useIsAuthenticated from './hooks/useIsAuthenticated'; import useOnyx from './hooks/useOnyx'; @@ -15,7 +17,7 @@ import {setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge} import {getReportIDFromLink} from './libs/ReportUtils'; import {endSpan} from './libs/telemetry/activeSpans'; import ONYXKEYS from './ONYXKEYS'; -import ROUTES, {type Route} from './ROUTES'; +import ROUTES from './ROUTES'; import {hasSeenTourSelector} from './selectors/Onboarding'; import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue'; From 5d4c0d82adb93f315fb99d74a8fea42801bcfb9c Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 7 Aug 2026 17:25:13 +0430 Subject: [PATCH 22/33] fix: navigate to Concierge chat after login from deep link on web --- src/DeepLinkHandler.tsx | 41 ---- src/hooks/useAutoCreateSubmitWorkspace.ts | 14 +- src/hooks/useAutoCreateTrackWorkspace.ts | 28 +-- src/hooks/useCompleteOnboarding.ts | 11 +- .../Navigation/linkingConfig/subscribe.ts | 40 +--- src/libs/PendingConciergeDeepLink.ts | 168 +-------------- src/libs/actions/Link.ts | 49 ++--- src/libs/actions/Report/index.ts | 4 - src/libs/actions/SignInRedirect.ts | 8 +- src/libs/navigateAfterOnboarding.ts | 85 ++------ .../BaseOnboardingPersonalDetails.tsx | 11 +- .../BaseOnboardingPurpose.tsx | 10 +- .../BaseOnboardingWorkspaces.tsx | 13 +- .../useAutoCreateSubmitWorkspace.test.ts | 21 +- .../unit/libs/navigateAfterOnboarding.test.ts | 24 --- tests/unit/navigateAfterOnboardingTest.ts | 199 +----------------- 16 files changed, 74 insertions(+), 652 deletions(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 29c2786d45b2..c6038ac7e8a1 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -12,13 +12,10 @@ import {openReportFromDeepLink} from './libs/actions/Link'; import * as Report from './libs/actions/Report'; import {hasAuthToken, isAnonymousUser} from './libs/actions/Session'; import Log from './libs/Log'; -import normalizePath from './libs/Navigation/helpers/normalizePath'; -import {setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge} from './libs/PendingConciergeDeepLink'; import {getReportIDFromLink} from './libs/ReportUtils'; import {endSpan} from './libs/telemetry/activeSpans'; import {hasSecureLinkKey} from './libs/Url'; import ONYXKEYS from './ONYXKEYS'; -import ROUTES from './ROUTES'; import {hasSeenTourSelector} from './selectors/Onboarding'; import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue'; @@ -27,36 +24,6 @@ type DeepLinkHandlerProps = { onInitialUrl: (url: Route | null) => void; }; -function getNormalizedCurrentPath() { - if (typeof window === 'undefined') { - return ''; - } - - return normalizePath(window.location.pathname).replace(/\/$/, '') || '/'; -} - -function getNormalizedPathFromURL(url: string) { - let path = url; - - try { - const parsedURL = new URL(url); - path = parsedURL.protocol === 'http:' || parsedURL.protocol === 'https:' ? parsedURL.pathname : `${parsedURL.host}${parsedURL.pathname}`; - } catch { - // If URL parsing fails, treat the value as a route path. - } - - return normalizePath(path).replace(/\/$/, '') || '/'; -} - -function isCurrentPathConcierge() { - return getNormalizedCurrentPath() === normalizePath(ROUTES.CONCIERGE); -} - -function isCurrentPathRootOrOnboarding() { - const normalizedPath = getNormalizedCurrentPath(); - return normalizedPath === '/' || normalizedPath === normalizePath(ROUTES.ONBOARDING_ROOT.route) || normalizedPath.startsWith(`${normalizePath(ROUTES.ONBOARDING_ROOT.route)}/`); -} - /** * Component that does not render anything but isolates the COLLECTION.REPORT Onyx subscription * from the root Expensify component to prevent cascading re-renders of the @@ -130,10 +97,6 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { initialUrlProcessed.current = true; onInitialUrl(url as Route); - if (!isCurrentlyAuthenticated && isCurrentPathConcierge() && (!url || getNormalizedPathFromURL(url) !== normalizePath(ROUTES.CONCIERGE))) { - setPendingConciergeDeepLink(); - } - if (url) { if (conciergeReportID === undefined) { Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url}); @@ -144,10 +107,6 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) { openReportFromDeepLink(url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected, isSelfTourViewed, betas); trackPendingPublicRoomFromDeepLink(url, isCurrentlyAuthenticated); } else { - if (!isCurrentlyAuthenticated && isCurrentPathRootOrOnboarding()) { - // A missing initial URL at root/onboarding can happen during startup, so don't override a stored /concierge intent. - setPendingHomeDeepLinkIfNoPendingConcierge(); - } Report.doneCheckingPublicRoom(); } diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 9dd59e1e1b37..7ed6cb46727a 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -1,5 +1,5 @@ import Log from '@libs/Log'; -import {navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {canEditWorkspaceSettings, isGroupPolicy, isSubmitPolicy} from '@libs/PolicyUtils'; @@ -88,7 +88,6 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, }) : {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID}; - let didNavigateToPendingDeepLink = false; if (shouldCompleteOnboarding) { try { @@ -102,9 +101,6 @@ function useAutoCreateSubmitWorkspace() { introSelected, isSelfTourViewed, conciergeChat, - onBeforeOnboardingModalUnmount: () => { - didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - }, }); } catch (error) { // Swallow onboarding completion failures so a network error doesn't block workspace @@ -126,12 +122,7 @@ function useAutoCreateSubmitWorkspace() { policyIDForNavigation = existingSubmitPolicyID; } - if (didNavigateToPendingDeepLink) { - return; - } - - // Pass conciergeReportID so true onboarding completion can honor a pending /concierge intent after refresh. - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID, shouldCompleteOnboarding); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); }, [ currentUserEmail, @@ -153,7 +144,6 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, shouldUseNarrowLayout, conciergeChat, - conciergeReportID, ], ); diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 07c6d57b03b3..1f139d0b823c 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -1,7 +1,7 @@ import isSidePanelReportSupported from '@components/SidePanel/isSidePanelReportSupported'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {createDisplayName} from '@libs/PersonalDetailsUtils'; import {isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -102,7 +102,6 @@ function useAutoCreateTrackWorkspace() { // On mobile, hardcode trackExpensesWithConcierge since the web flow already works // with the CompleteGuidedSetup response and side panel isn't supported on native. let rhpVariant: OnboardingRHPVariant | undefined = isSidePanelReportSupported ? undefined : CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE; - let didNavigateToPendingDeepLink = false; try { const response = await completeOnboarding({ engagementChoice, @@ -117,9 +116,6 @@ function useAutoCreateTrackWorkspace() { isSelfTourViewed, conciergeChat, selfDMReport, - onBeforeOnboardingModalUnmount: () => { - didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); - }, }); if (isSidePanelReportSupported) { @@ -137,18 +133,16 @@ function useAutoCreateTrackWorkspace() { setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); - if (!didNavigateToPendingDeepLink) { - navigateAfterOnboardingWithMicrotaskQueue( - shouldUseNarrowLayout, - isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), - conciergeChatReportID, - reportNameValuePairs, - newPolicyID, - mergedAccountConciergeReportID, - false, - {variantOverride: rhpVariant}, - ); - } + navigateAfterOnboardingWithMicrotaskQueue( + shouldUseNarrowLayout, + isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), + conciergeChatReportID, + reportNameValuePairs, + newPolicyID, + mergedAccountConciergeReportID, + false, + {variantOverride: rhpVariant}, + ); } }, [ diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 46b4cbe2d8fe..6addbe7952fd 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -5,7 +5,7 @@ import {completeOnboarding, extractRHPVariantFromResponse} from '@libs/actions/R import {setOnboardingAdminsChatReportID, setOnboardingPolicyID} from '@libs/actions/Welcome'; import type {OnboardingFeatureMapItem} from '@libs/actions/Welcome/OnboardingFeatures'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import {isGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; @@ -72,7 +72,6 @@ function useCompleteOnboarding() { const isAccountingEnabled = featuresMap.some((feature) => feature.id === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED && feature.enabled); const resolvedIntegration = isAccountingEnabled ? userReportedIntegration : undefined; const email = currentUserPersonalDetails.email ?? ''; - let didNavigateToPendingDeepLink = false; const {adminsChatReportID, policyID} = shouldCreateWorkspace ? createWorkspace({ @@ -119,9 +118,6 @@ function useCompleteOnboarding() { isSelfTourViewed, conciergeChat, adminsChatReport, - onBeforeOnboardingModalUnmount: () => { - didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - }, }); const rhpVariant = isSidePanelReportSupported ? extractRHPVariantFromResponse(response) : undefined; @@ -133,11 +129,6 @@ function useCompleteOnboarding() { waitForUpcomingTransition: true, }); - if (didNavigateToPendingDeepLink) { - setIsLoading(false); - return; - } - navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), diff --git a/src/libs/Navigation/linkingConfig/subscribe.ts b/src/libs/Navigation/linkingConfig/subscribe.ts index 91b6b0ff938d..cafdecca095b 100644 --- a/src/libs/Navigation/linkingConfig/subscribe.ts +++ b/src/libs/Navigation/linkingConfig/subscribe.ts @@ -1,9 +1,7 @@ import {hasAuthToken} from '@libs/actions/Session'; import continuePlaidOAuth from '@libs/continuePlaidOAuth'; -import normalizePath from '@libs/Navigation/helpers/normalizePath'; import navigationRef from '@libs/Navigation/navigationRef'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; -import {updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -13,8 +11,6 @@ import type {LinkingOptions} from '@react-navigation/native'; import {findFocusedRoute} from '@react-navigation/native'; import {Linking} from 'react-native'; -import prefixes from './prefixes'; - /** * Rules for dropping a deep link that would re-navigate to a screen the user is already on. */ @@ -33,40 +29,8 @@ const skipRules: ReadonlyArray<{urlMatcher: RegExp; focusedScreens: readonly str }, ]; -function isInternalAppURL(url: string) { - if (url.startsWith('/') || prefixes.some((prefix) => url.startsWith(prefix))) { - return true; - } - - try { - return typeof window !== 'undefined' && new URL(url).origin === window.location.origin; - } catch { - return false; - } -} - -function getNormalizedPathFromURL(url: string) { - let path = url; - - try { - const parsedURL = new URL(url); - path = parsedURL.protocol === 'http:' || parsedURL.protocol === 'https:' || parsedURL.pathname ? parsedURL.pathname : parsedURL.host; - } catch { - // If URL parsing fails, treat the value as a route path. - } - - return (normalizePath(path).replace(/\/$/, '') || '/').toLowerCase(); -} - -const subscribe: NonNullable['subscribe']> = (listener) => { +const subscribe: LinkingOptions['subscribe'] = (listener) => { const subscription = Linking.addEventListener('url', ({url}: {url: string}) => { - const isAuthenticated = hasAuthToken(); - const normalizedPath = getNormalizedPathFromURL(url); - const route = normalizedPath === '/' ? '' : normalizedPath.slice(1); - if (!isAuthenticated && isInternalAppURL(url)) { - updatePendingConciergeDeepLinkForRoute(route, isAuthenticated); - } - // Skip deep links to screens where the user is already focused. const skipRule = skipRules.find(({urlMatcher}) => urlMatcher.test(url)); if (skipRule) { @@ -91,7 +55,7 @@ const subscribe: NonNullable['subscribe'] // which lives in AuthScreens and is not mounted while PublicScreens is showing. Dispatching it here // throws "NAVIGATE ... was not handled by any navigator". openReportFromDeepLink() already opens the // public room as an anonymous user and handles navigation, so defer to it instead. See #92672. - if (!isAuthenticated && url.includes(`/${ROUTES.REPORT}/`)) { + if (!hasAuthToken() && url.includes(`/${ROUTES.REPORT}/`)) { return; } listener(url); diff --git a/src/libs/PendingConciergeDeepLink.ts b/src/libs/PendingConciergeDeepLink.ts index 948f5315956a..94e7592c49d4 100644 --- a/src/libs/PendingConciergeDeepLink.ts +++ b/src/libs/PendingConciergeDeepLink.ts @@ -1,171 +1,17 @@ -import ROUTES from '@src/ROUTES'; - -import isPublicScreenRoute from './isPublicScreenRoute'; -import normalizePath from './Navigation/helpers/normalizePath'; - -/** - * Tracks whether a logged-out user opened a /concierge deep link so the app can - * route them to Concierge after sign-up/onboarding. sessionStorage keeps the - * tab-scoped intent across page reloads while sign-out/consume paths clear it. - */ -const PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY = 'PENDING_CONCIERGE_DEEP_LINK'; -const LEGACY_PERFORMANCE_NAVIGATION_KEY = 'navigation'; -const LEGACY_PERFORMANCE_NAVIGATION_TYPE_KEY = 'type'; -const LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD = 1; let hasPendingConciergeDeepLink = false; -function getSessionStorage() { - try { - return typeof window === 'undefined' ? undefined : window.sessionStorage; - } catch { - return undefined; - } -} - -function getStoredValue(key: string, getStorage: () => Storage | undefined) { - try { - return getStorage()?.getItem(key); - } catch { - return undefined; - } -} - -function setStoredValue(key: string, value: string, getStorage: () => Storage | undefined) { - try { - getStorage()?.setItem(key, value); - } catch { - // Ignore storage failures and keep the in-memory intent for the current page lifecycle. - } -} - -function clearStoredValue(key: string, getStorage: () => Storage | undefined) { - try { - getStorage()?.removeItem(key); - } catch { - // Ignore storage failures since clearing the in-memory flag is still enough for this page lifecycle. - } -} - -function hasStoredFlag(key: string) { - return getStoredValue(key, getSessionStorage) === 'true'; -} - -function setStoredFlag(key: string) { - setStoredValue(key, 'true', getSessionStorage); -} - -function clearStoredFlag(key: string) { - clearStoredValue(key, getSessionStorage); -} - -function hasPendingConciergeDeepLinkFlag() { - return hasPendingConciergeDeepLink || hasStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); -} - -function clearPendingConciergeDeepLink() { - hasPendingConciergeDeepLink = false; - clearStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); -} - function setPendingConciergeDeepLink() { hasPendingConciergeDeepLink = true; - setStoredFlag(PENDING_CONCIERGE_DEEP_LINK_STORAGE_KEY); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isBrowserReload() { - try { - // A browser refresh during signup can replay root/home even though the stored Concierge intent is still valid. - const performance = typeof window === 'undefined' ? undefined : window.performance; - const navigationEntries = performance?.getEntriesByType?.('navigation') ?? []; - if (navigationEntries.some((entry) => 'type' in entry && entry.type === 'reload')) { - return true; - } - - // Some web runtimes only expose the deprecated navigation API, so read it indirectly to keep the fallback without triggering deprecated API lint. - const legacyNavigation: unknown = performance ? Reflect.get(performance, LEGACY_PERFORMANCE_NAVIGATION_KEY) : undefined; - return isRecord(legacyNavigation) && legacyNavigation[LEGACY_PERFORMANCE_NAVIGATION_TYPE_KEY] === LEGACY_PERFORMANCE_NAVIGATION_TYPE_RELOAD; - } catch { - return false; - } -} - -function isCurrentBrowserPathOnboarding() { - try { - if (typeof window === 'undefined') { - return false; - } - - const normalizedPath = getNormalizedRouteWithoutParams(window.location.pathname); - return isOnboardingRoute(normalizedPath); - } catch { - return false; - } -} - -function getNormalizedRouteWithoutParams(route: string) { - const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); - return routeWithoutParams.replace(/\/$/, '') || '/'; -} - -function isOnboardingRoute(normalizedRoute: string) { - // Onboarding URLs are generated by the guided setup flow, so they should not replace the original signup deep-link intent. - return normalizedRoute === normalizePath(ROUTES.ONBOARDING_ROOT.route) || normalizedRoute.startsWith(`${normalizePath(ROUTES.ONBOARDING_ROOT.route)}/`); -} - -function setPendingHomeDeepLinkIfNoPendingConcierge() { - // Startup/linking can emit ambiguous root/home signals, so avoid replacing an explicit /concierge intent. - if (hasPendingConciergeDeepLinkFlag()) { - return; - } - clearPendingConciergeDeepLink(); -} - -function isAmbiguousStartupOrOnboardingReplay(normalizedRoute: string) { - return isOnboardingRoute(normalizedRoute) || (normalizedRoute === '/' && (isBrowserReload() || isCurrentBrowserPathOnboarding())); -} - -// Keep pending signup deep-link intent consistent across initial URL handling and later Linking URL events. -function updatePendingConciergeDeepLinkForRoute(route: string, isAuthenticated: boolean) { - const normalizedRoute = getNormalizedRouteWithoutParams(route); - const routeForPublicScreen = normalizedRoute === '/' ? '' : normalizedRoute.slice(1); - - if (hasPendingConciergeDeepLinkFlag() && isAmbiguousStartupOrOnboardingReplay(normalizedRoute)) { - return; - } - - if (isAuthenticated) { - if (normalizedRoute === '/') { - clearPendingConciergeDeepLink(); - return; - } - if (normalizedRoute === normalizePath(ROUTES.HOME) || isOnboardingRoute(normalizedRoute)) { - return; - } - if (normalizedRoute !== normalizePath(ROUTES.CONCIERGE) && !isPublicScreenRoute(routeForPublicScreen)) { - clearPendingConciergeDeepLink(); - } - return; - } - - if (normalizedRoute === normalizePath(ROUTES.CONCIERGE)) { - setPendingConciergeDeepLink(); - } else if (normalizedRoute === '/') { - clearPendingConciergeDeepLink(); - } else if (normalizedRoute === normalizePath(ROUTES.HOME)) { - setPendingHomeDeepLinkIfNoPendingConcierge(); - } else if (!isPublicScreenRoute(routeForPublicScreen)) { - clearPendingConciergeDeepLink(); - } } function consumePendingConciergeDeepLink() { - const shouldNavigateToConcierge = hasPendingConciergeDeepLinkFlag(); - clearPendingConciergeDeepLink(); + const shouldNavigateToConcierge = hasPendingConciergeDeepLink; + hasPendingConciergeDeepLink = false; return shouldNavigateToConcierge; } -export {setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge, updatePendingConciergeDeepLinkForRoute, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink}; +function clearPendingConciergeDeepLink() { + hasPendingConciergeDeepLink = false; +} + +export {setPendingConciergeDeepLink, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index b16b0e0b7149..e39bd54ade0a 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -16,7 +16,7 @@ import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import REPORT_LINK_ROUTE_PARAMS from '@libs/Navigation/reportLinkRouteParams'; import {getIsOffline} from '@libs/NetworkState'; -import {setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import {findLastAccessedReport, getReportIDFromLink, getReportOrDraftReport, getRouteFromLink, isMoneyRequestReport} from '@libs/ReportUtils'; import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation'; import {endSpan, getSpan, startSpan} from '@libs/telemetry/activeSpans'; @@ -452,6 +452,12 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { openExternalLink(href); } +function isConciergeRoute(route: string) { + const [routeWithoutParams] = normalizePath(route).split(/[?#]/, 1); + const normalizedRoute = routeWithoutParams.replace(/\/$/, ''); + return normalizedRoute === normalizePath(ROUTES.CONCIERGE); +} + function openReportFromDeepLink( url: string, reports: OnyxCollection, @@ -497,7 +503,13 @@ function openReportFromDeepLink( route = ''; } - updatePendingConciergeDeepLinkForRoute(route, isAuthenticated); + if (!isAuthenticated) { + if (isConciergeRoute(route)) { + setPendingConciergeDeepLink(); + } else { + clearPendingConciergeDeepLink(); + } + } // If we are not authenticated and are navigating to a public screen, we don't want to navigate again to the screen after sign-in/sign-up if (!isAuthenticated && isPublicScreenRoute(route)) { @@ -516,30 +528,6 @@ function openReportFromDeepLink( // Navigate to the report after sign-in/sign-up. waitForUserSignIn().then(() => { - // A Submit-via-PDF secure access link must reach the report regardless of onboarding status: the report screen - // is where JoinReportViaSecureLink runs, and onboarding is suppressed for secure-link visitors. The generic - // handling below intentionally drops deep links for users who still need to onboard, so branch out first. - if (Url.hasSecureLinkKey(route)) { - Navigation.waitForProtectedRoutes().then(() => { - // Secure links grant workspace + report access to a real account via JoinReportViaSecureLink, so an - // anonymous session can never fulfill them even though report routes are otherwise anonymous-accessible - // (canAnonymousUserAccessRoute would allow it). Force a real sign-in first; the deep link is re-processed - // after sign-in. Without this the user lands on /r/:id?secureKey with no join, stuck loading/404. - if (isAnonymousUser()) { - signOutAndRedirectToSignIn(true); - return; - } - // On cold launch the report is already the initial route; navigating again would stack a duplicate - // that renders "not found" until the join grants access. Only navigate when we're not already there. - if (Navigation.getTopmostReportId() === reportID) { - return; - } - const secureKey = new URLSearchParams(route.split('?').at(1) ?? '').get('secureKey') ?? undefined; - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, undefined, secureKey), {waitForTransition: true}); - }); - return; - } - // `false` when the user still had to onboard as this deep link was captured (fresh sign-up, or a // stale react-native-web URL); honoring it after onboarding flashes the "Not here" page (#91437). let initialHasCompletedGuidedSetupFlow: boolean | undefined; @@ -577,14 +565,7 @@ function openReportFromDeepLink( const state = navigationRef.getRootState(); const currentFocusedRoute = findFocusedRoute(state); - const isConciergeRoute = normalizePath(route) === normalizePath(ROUTES.CONCIERGE); - const isOnboardingFlowFocused = isOnboardingFlowName(currentFocusedRoute?.name); - - if (isConciergeRoute && (initialHasCompletedGuidedSetupFlow === false || isOnboardingFlowFocused)) { - setPendingConciergeDeepLink(); - } - - if (isOnboardingFlowFocused) { + if (isOnboardingFlowName(currentFocusedRoute?.name)) { setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); return; } diff --git a/src/libs/actions/Report/index.ts b/src/libs/actions/Report/index.ts index 333091fbe792..da9cf10cfde7 100644 --- a/src/libs/actions/Report/index.ts +++ b/src/libs/actions/Report/index.ts @@ -5630,7 +5630,6 @@ type CompleteOnboardingProps = { adminsChatReport?: OnyxEntry; /** The self-DM report, looked up by ONYXKEYS.SELF_DM_REPORT_ID. */ selfDMReport?: OnyxEntry; - onBeforeOnboardingModalUnmount?: () => void; }; async function completeOnboarding({ @@ -5654,7 +5653,6 @@ async function completeOnboarding({ conciergeChat, adminsChatReport, selfDMReport, - onBeforeOnboardingModalUnmount, }: CompleteOnboardingProps) { const onboardingData = prepareOnboardingOnyxData({ introSelected, @@ -5703,7 +5701,6 @@ async function completeOnboarding({ // during the wait. Must run before the API call so useLinking processes each step // pop before the optimistic data unmounts the modal. resetOnboardingStackToRoot(); - onBeforeOnboardingModalUnmount?.(); // We need to access the nvp_onboardingRHPVariant directly from the response to redirect the user to the correct page // eslint-disable-next-line rulesdir/no-api-side-effects-method @@ -5713,7 +5710,6 @@ async function completeOnboarding({ // Pop onboarding nested stack just before the API write so useLinking removes browser // history entries for each step before the optimistic data unmounts the modal. resetOnboardingStackToRoot(); - onBeforeOnboardingModalUnmount?.(); // API calls are not chained in this case // eslint-disable-next-line rulesdir/no-multiple-api-calls diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index f7dccaf20d44..2ec1643244bc 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -2,7 +2,6 @@ import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getIsOffline} from '@libs/NetworkState'; import {clearPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; -import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import CONFIG from '@src/CONFIG'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -79,7 +78,7 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: keysToPreserve.push(ONYXKEYS.NETWORK); } - // When the user is in the middle of a 2FA sign-in flow (they've entered their validateCode but not yet completed + // When the user is in the middle of a 2FA sign-in flow (they've entered their magic code but not yet completed // 2FA), we want to preserve their credentials and account state so that after a page refresh they are still // prompted to enter their 2FA code rather than being sent back to the initial sign-in page. const isIncompleteSignIn = !currentSessionAuthToken && !!currentCredentialsValidateCode; @@ -96,10 +95,7 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true}); } - return Onyx.clear(keysToPreserve).then(async () => { - // Requests may be processed while sign-out is in progress. Clear again after credentials have been removed so none of those requests remain queued for the next startup. - await clearPrefetchOnAppStart(); - + return Onyx.clear(keysToPreserve).then(() => { if (CONFIG.IS_HYBRID_APP) { resetSignInFlow(); HybridAppModule.signOutFromOldDot(); diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 950ecf28f960..d40f27c785ed 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -13,8 +13,6 @@ import Onyx from 'react-native-onyx'; import {setDisableDismissOnEscape} from './actions/Modal'; import SidePanelActions from './actions/SidePanel'; import {setOnboardingRHPVariant} from './actions/Welcome'; -import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostSplitNavigator'; -import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; import {consumePendingConciergeDeepLink} from './PendingConciergeDeepLink'; @@ -28,33 +26,6 @@ Onyx.connectWithoutView({ }, }); -type NavigateAfterOnboardingOptions = { - afterTransition?: () => void; - variantOverride?: OnboardingRHPVariant | null; -}; - -function getPendingDeepLinkRouteAfterOnboarding(conciergeReportID?: string): Route | undefined { - const shouldNavigateToConciergeFromDeepLink = consumePendingConciergeDeepLink(); - - if (shouldNavigateToConciergeFromDeepLink) { - // The report ID can still be unavailable immediately after signup refresh, so fall back to the Concierge route. - return conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route); - } - - return undefined; -} - -function navigateToPendingDeepLinkAfterOnboarding(conciergeReportID?: string) { - const pendingDeepLinkRoute = getPendingDeepLinkRouteAfterOnboarding(conciergeReportID); - if (!pendingDeepLinkRoute) { - return false; - } - - setDisableDismissOnEscape(false); - Navigation.navigate(pendingDeepLinkRoute); - return true; -} - /** * Determines the report ID to navigate to after onboarding for control variant or ineligible users. * On large screens, navigates to the admins chat if available. On small screens, finds the last @@ -99,29 +70,22 @@ function navigateAfterOnboarding( onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, - options?: NavigateAfterOnboardingOptions, + variantOverride?: OnboardingRHPVariant | null, ) { setDisableDismissOnEscape(false); - // Resolve signup deep-link intents before onboarding variants, so /concierge wins unless the user explicitly replaced it with /. - if (navigateToPendingDeepLinkAfterOnboarding(conciergeReportID)) { - return; - } - // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native // (Side Panel doesn't exist on native), but we still need to navigate to Concierge on mobile. - const navigationOptions = options?.afterTransition ? {afterTransition: options.afterTransition} : undefined; - const variantOverride = options?.variantOverride; const variant = variantOverride ?? onboardingRHPVariant; if (isSmallScreenWidth && variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID), navigationOptions); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID)); return; } if (shouldOpenRHPVariant(variantOverride)) { - handleRHPVariantNavigation(onboardingPolicyID, variantOverride, navigationOptions); + handleRHPVariantNavigation(onboardingPolicyID, variantOverride); return; } @@ -135,10 +99,12 @@ function navigateAfterOnboarding( shouldPreventOpenAdminRoom, ); if (reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID), navigationOptions); - } else if (!isReportTopmostSplitNavigator()) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } else if (consumePendingConciergeDeepLink()) { + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + } else { // Navigate to home to trigger guard evaluation - Navigation.navigate(ROUTES.HOME, navigationOptions); + Navigation.navigate(ROUTES.HOME); } } @@ -150,16 +116,9 @@ function navigateAfterOnboardingWithMicrotaskQueue( onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, - options?: NavigateAfterOnboardingOptions, + variantOverride?: OnboardingRHPVariant | null, ) { - dismissOnboardingModalBeforeExit(); - const pendingDeepLinkRoute = getPendingDeepLinkRouteAfterOnboarding(conciergeReportID); - if (pendingDeepLinkRoute) { - setDisableDismissOnEscape(false); - Navigation.navigate(pendingDeepLinkRoute, options?.afterTransition ? {afterTransition: options.afterTransition} : undefined); - return; - } - + Navigation.dismissModal(); Navigation.setNavigationActionToMicrotaskQueue(() => { navigateAfterOnboarding( isSmallScreenWidth, @@ -169,7 +128,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( onboardingPolicyID, onboardingAdminsChatReportID, shouldPreventOpenAdminRoom, - options, + variantOverride, ); }); } @@ -179,14 +138,9 @@ function navigateAfterOnboardingWithMicrotaskQueue( * navigate to Workspace > Categories with the side panel open so * the #admins room is visible in Concierge Anywhere. */ -function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string, shouldHonorPendingDeepLink = true) { +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { setDisableDismissOnEscape(false); - // Submit workspace onboarding bypasses navigateAfterOnboarding(), so honor the same pending deep-link intent here. - if (shouldHonorPendingDeepLink && navigateToPendingDeepLinkAfterOnboarding(conciergeReportID)) { - return; - } - if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -201,18 +155,11 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } -function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string, shouldHonorPendingDeepLink = true) { - dismissOnboardingModalBeforeExit(); - const pendingDeepLinkRoute = shouldHonorPendingDeepLink ? getPendingDeepLinkRouteAfterOnboarding(conciergeReportID) : undefined; - if (shouldHonorPendingDeepLink && pendingDeepLinkRoute) { - setDisableDismissOnEscape(false); - Navigation.navigate(pendingDeepLinkRoute); - return; - } - +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false) { + Navigation.dismissModal(); Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout, conciergeReportID, shouldHonorPendingDeepLink); + navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout); }); } -export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx index 0426e9e2ebfc..2721113e3dda 100644 --- a/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx +++ b/src/pages/OnboardingPersonalDetails/BaseOnboardingPersonalDetails.tsx @@ -20,7 +20,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {addErrorMessage} from '@libs/ErrorUtils'; import Log from '@libs/Log'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {hasURL} from '@libs/Url'; @@ -94,7 +94,6 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat } setIsLoading(true); - let didNavigateToPendingDeepLink = false; try { await completeOnboardingReport({ engagementChoice: onboardingPurposeSelected, @@ -106,19 +105,11 @@ function BaseOnboardingPersonalDetails({currentUserPersonalDetails, shouldUseNat introSelected, isSelfTourViewed, conciergeChat, - onBeforeOnboardingModalUnmount: () => { - didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID); - }, }); setOnboardingAdminsChatReportID(); setOnboardingPolicyID(); - if (didNavigateToPendingDeepLink) { - setIsLoading(false); - return; - } - navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), diff --git a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx index 2ce572912295..98e485bced94 100644 --- a/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx +++ b/src/pages/OnboardingPurpose/BaseOnboardingPurpose.tsx @@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import OnboardingRefManager from '@libs/OnboardingRefManager'; import type {TOnboardingRef} from '@libs/OnboardingRefManager'; @@ -139,7 +139,6 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro autoCreateTrackWorkspace(personalDetailsForm.firstName, personalDetailsForm.lastName ?? '', choice); return; } - let didNavigateToPendingDeepLink = false; completeOnboarding({ engagementChoice: choice, onboardingMessage: onboardingMessages[choice], @@ -152,14 +151,7 @@ function BaseOnboardingPurpose({shouldUseNativeStyles, shouldEnableMaxHeight, ro isSelfTourViewed, conciergeChat, adminsChatReport, - onBeforeOnboardingModalUnmount: () => { - didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - }, }).then(() => { - if (didNavigateToPendingDeepLink) { - return; - } - navigateAfterOnboardingWithMicrotaskQueue( shouldUseNarrowLayout, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index 0a5bc248a721..36b7d9a14c94 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -17,7 +17,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {navigateAfterOnboardingWithMicrotaskQueue, navigateToPendingDeepLinkAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {expensifyLoginsSelector, isCurrentUserValidated} from '@libs/UserUtils'; @@ -85,7 +85,6 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding const handleJoinWorkspace = (policy: JoinablePolicy) => { const isJoiningSubmitPolicy = policy.policyType === CONST.POLICY.TYPE.SUBMIT; const shouldUseSubmitFlow = canUseSubmit2026 && policy.automaticJoiningEnabled && isJoiningSubmitPolicy; - let didNavigateToPendingDeepLink = false; if (policy.automaticJoiningEnabled) { joinAccessiblePolicy(policy.policyID); @@ -102,20 +101,12 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding introSelected, isSelfTourViewed, conciergeChat, - onBeforeOnboardingModalUnmount: () => { - didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID); - }, }); setOnboardingAdminsChatReportID(); setOnboardingPolicyID(policy.policyID); - if (didNavigateToPendingDeepLink) { - return; - } - if (shouldUseSubmitFlow) { - // The Submit workspace path bypasses navigateAfterOnboarding(), so pass conciergeReportID for pending /concierge redirects. - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout, conciergeReportID); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout); return; } diff --git a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts index cf16b0aa834e..86b03581c251 100644 --- a/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts +++ b/tests/unit/hooks/useAutoCreateSubmitWorkspace.test.ts @@ -41,7 +41,7 @@ const MOCK_POLICY_ID = 'mock-policy-id'; const MOCK_ADMINS_CHAT_REPORT_ID = 'mock-admins-chat-report-id'; const MOCK_ONBOARDING_MESSAGE = {message: 'Welcome!', video: undefined, tasks: []}; -function setupDefaultMocks({conciergeReportID}: {conciergeReportID?: string} = {}) { +function setupDefaultMocks() { mockUseOnyx.mockImplementation((key: string) => { if (key === 'session') { return [MOCK_SESSION]; @@ -49,9 +49,6 @@ function setupDefaultMocks({conciergeReportID}: {conciergeReportID?: string} = { if (key === 'betas') { return [[]]; } - if (key === 'conciergeReportID') { - return [conciergeReportID]; - } if (key.startsWith('policy_')) { return [false]; } @@ -169,17 +166,7 @@ describe('useAutoCreateSubmitWorkspace', () => { // Then the user should be navigated to the newly created Submit workspace // so they land on their workspace immediately after onboarding expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), undefined, true); - }); - - it('passes the Concierge report ID to submit workspace navigation when available', async () => { - setupDefaultMocks({conciergeReportID: 'concierge-report-id'}); - - const {result} = renderHook(() => useAutoCreateSubmitWorkspace()); - await result.current('John', 'Doe'); - - expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean), 'concierge-report-id', true); + expect(navigateSpy).toHaveBeenCalledWith(MOCK_POLICY_ID, expect.any(Boolean)); }); it('reuses the existing onboarding workspace instead of creating a new one', () => { @@ -328,7 +315,7 @@ describe('useAutoCreateSubmitWorkspace', () => { expect(createWorkspaceSpy).not.toHaveBeenCalled(); expect(completeOnboardingSpy).not.toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(existingSubmitPolicy.id, expect.any(Boolean), undefined, false); + expect(navigateSpy).toHaveBeenCalledWith(existingSubmitPolicy.id, expect.any(Boolean)); }); it('keeps the Home fallback for onboarding callers when creation is skipped', async () => { @@ -363,7 +350,7 @@ describe('useAutoCreateSubmitWorkspace', () => { // behavior (landing on Home) so this fix stays scoped to already-onboarded callers expect(createWorkspaceSpy).not.toHaveBeenCalled(); expect(navigateSpy).toHaveBeenCalledTimes(1); - expect(navigateSpy).toHaveBeenCalledWith(undefined, expect.any(Boolean), undefined, true); + expect(navigateSpy).toHaveBeenCalledWith(undefined, expect.any(Boolean)); }); it('uses the localCurrencyCode from personal details for workspace currency', () => { diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index ee29b2d69366..171e6f6f0c9f 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -1,6 +1,5 @@ import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import {clearPendingConciergeDeepLink, consumePendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import ROUTES from '@src/ROUTES'; @@ -31,7 +30,6 @@ const navigationMock = Navigation as jest.Mocked; describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { beforeEach(() => { jest.clearAllMocks(); - clearPendingConciergeDeepLink(); }); it('navigates to HOME when policyID is missing', () => { @@ -59,26 +57,4 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { `${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACE_INITIAL.getRoute('test-policy-id'))}`, ); }); - - it('navigates to pending Concierge before Workspace Categories', () => { - setPendingConciergeDeepLink(); - - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false, 'concierge-report-id'); - - expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('concierge-report-id')); - expect(navigationMock.setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); - }); - - it('does not consume pending Concierge from the Submit welcome modal path', () => { - setPendingConciergeDeepLink(); - - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false, 'concierge-report-id', false); - - expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledTimes(1); - expect(navigationMock.navigate).toHaveBeenCalledWith(`${ROUTES.WORKSPACE_CATEGORIES.getRoute('test-policy-id')}?backTo=${encodeURIComponent(ROUTES.WORKSPACES_LIST.route)}`); - expect(consumePendingConciergeDeepLink()).toBe(true); - }); }); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 249e735a3856..e3b83d7e3ee1 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,8 +1,6 @@ -import {openReportFromDeepLink} from '@libs/actions/Link'; -import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; -import {clearPendingConciergeDeepLink, consumePendingConciergeDeepLink, setPendingConciergeDeepLink, updatePendingConciergeDeepLinkForRoute} from '@libs/PendingConciergeDeepLink'; -import type * as PendingConciergeDeepLink from '@libs/PendingConciergeDeepLink'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import type * as ReportUtils from '@libs/ReportUtils'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; @@ -24,62 +22,6 @@ const REPORT_ID = '3'; const USER_ID = '4'; const mockFindLastAccessedReport = jest.fn, Parameters>(); const mockShouldOpenOnAdminRoom = jest.fn(); -const mockIsReportTopmostSplitNavigator = jest.fn(() => false); - -function mockBrowserReloadNavigation(useLegacyFallback = false) { - const originalGetEntriesByType = Object.getOwnPropertyDescriptor(window.performance, 'getEntriesByType'); - const originalNavigation = Object.getOwnPropertyDescriptor(window.performance, 'navigation'); - Object.defineProperty(window.performance, 'getEntriesByType', { - configurable: true, - value: jest.fn((type: string) => { - if (type !== 'navigation') { - return []; - } - return useLegacyFallback ? [] : [{type: 'reload'}]; - }), - }); - - if (useLegacyFallback) { - Object.defineProperty(window.performance, 'navigation', { - configurable: true, - value: {type: 1}, - }); - } - - return () => { - if (originalGetEntriesByType) { - Object.defineProperty(window.performance, 'getEntriesByType', originalGetEntriesByType); - } else { - Reflect.deleteProperty(window.performance, 'getEntriesByType'); - } - - if (originalNavigation) { - Object.defineProperty(window.performance, 'navigation', originalNavigation); - } else { - Reflect.deleteProperty(window.performance, 'navigation'); - } - }; -} - -jest.mock('@expensify/react-native-hybrid-app', () => ({ - __esModule: true, - default: { - isHybridApp: jest.fn(() => false), - shouldUseStaging: jest.fn(), - closeReactNativeApp: jest.fn(), - completeOnboarding: jest.fn(), - switchAccount: jest.fn(), - sendAuthToken: jest.fn(), - getHybridAppSettings: jest.fn(() => Promise.resolve(null)), - getInitialURL: jest.fn(() => Promise.resolve(null)), - onURLListenerAdded: jest.fn(), - signInToOldDot: jest.fn(), - signOutFromOldDot: jest.fn(), - startSignOut: jest.fn(), - cancelSignOut: jest.fn(), - clearOldDotAfterSignOut: jest.fn(), - }, -})); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -92,8 +34,6 @@ jest.mock('@react-navigation/native', () => { jest.mock('@libs/ReportUtils', () => ({ findLastAccessedReport: (...args: Parameters) => mockFindLastAccessedReport(...args), - getReportIDFromLink: jest.requireActual('@libs/ReportUtils').getReportIDFromLink, - getRouteFromLink: jest.requireActual('@libs/ReportUtils').getRouteFromLink, parseReportRouteParams: jest.fn(() => ({})), isConciergeChatReport: jest.requireActual('@libs/ReportUtils').isConciergeChatReport, isArchivedReport: jest.requireActual('@libs/ReportUtils').isArchivedReport, @@ -114,18 +54,6 @@ jest.mock('@libs/Navigation/helpers/shouldOpenOnAdminRoom', () => ({ default: () => mockShouldOpenOnAdminRoom() as boolean, })); -jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => ({ - __esModule: true, - default: () => mockIsReportTopmostSplitNavigator(), -})); - -jest.mock('@libs/actions/SidePanel', () => ({ - __esModule: true, - default: { - openSidePanel: jest.fn(), - }, -})); - describe('navigateAfterOnboarding', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -136,7 +64,6 @@ describe('navigateAfterOnboarding', () => { beforeEach(async () => { jest.clearAllMocks(); clearPendingConciergeDeepLink(); - mockIsReportTopmostSplitNavigator.mockReturnValue(false); return Onyx.clear(); }); @@ -145,24 +72,15 @@ describe('navigateAfterOnboarding', () => { const testSession = {email: 'realaccount@gmail.com'}; navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); }); - it('should navigate to home if onboardingAdminsChatReportID is not provided on larger screens and no report is topmost', () => { + it('should not navigate to the admin room report if onboardingAdminsChatReportID is not provided on larger screens', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - navigateAfterOnboarding(false, true, '', {}, undefined, undefined); // Without an admins chat report, we fall back to HOME to trigger guard evaluation instead of opening a report. expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); - }); - - it('should preserve the topmost report if onboardingAdminsChatReportID is not provided on larger screens', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - - navigateAfterOnboarding(false, true, '', {}, undefined, undefined); - expect(navigate).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); }); it('should not navigate to last accessed report if it is a concierge chat on small screens', async () => { @@ -210,7 +128,7 @@ describe('navigateAfterOnboarding', () => { mockShouldOpenOnAdminRoom.mockReturnValue(true); navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); it('should pass reportNameValuePairs when looking up last accessed report', () => { @@ -231,13 +149,13 @@ describe('navigateAfterOnboarding', () => { const testSession = {email: 'test+account@gmail.com'}; navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); }); it('should navigate to the admin room when the inboxAdminsBespoke variant is assigned', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE}); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); }); it('should navigate to Concierge instead of Home when a pending Concierge deep link is available', () => { @@ -250,37 +168,6 @@ describe('navigateAfterOnboarding', () => { expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); - it('should navigate to pending Concierge immediately when exiting onboarding', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - const setNavigationActionToMicrotaskQueue = jest.spyOn(Navigation, 'setNavigationActionToMicrotaskQueue'); - setPendingConciergeDeepLink(); - - navigateAfterOnboardingWithMicrotaskQueue(false, true, REPORT_ID, {}, undefined, undefined); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); - expect(setNavigationActionToMicrotaskQueue).not.toHaveBeenCalled(); - }); - - it('should navigate to Concierge instead of the onboarding admin room when a pending Concierge deep link is available', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - setPendingConciergeDeepLink(); - - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); - }); - - it('should navigate to Concierge instead of the onboarding RHP variant when a pending Concierge deep link is available', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - setPendingConciergeDeepLink(); - - navigateAfterOnboarding(false, true, REPORT_ID, {}, ONBOARDING_POLICY_ID, undefined, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE}); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); - }); - it('should navigate to Concierge route when pending deep link is set but conciergeReportID is empty', () => { const navigate = jest.spyOn(Navigation, 'navigate'); setPendingConciergeDeepLink(); @@ -299,72 +186,6 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); expect(navigate).toHaveBeenNthCalledWith(1, ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME, undefined); - }); - - it('should preserve the pending Concierge deep link across a module reload', () => { - setPendingConciergeDeepLink(); - - jest.isolateModules(() => { - const {consumePendingConciergeDeepLink: consumePendingConciergeDeepLinkAfterReload} = jest.requireActual('@libs/PendingConciergeDeepLink'); - expect(consumePendingConciergeDeepLinkAfterReload()).toBe(true); - }); - - expect(window.sessionStorage.getItem('PENDING_CONCIERGE_DEEP_LINK')).toBeNull(); - clearPendingConciergeDeepLink(); - }); - - it('should preserve a pending Concierge deep link when a generated home route is processed during reload', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - setPendingConciergeDeepLink(); - - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/${ROUTES.HOME}`, {}, false, REPORT_ID, undefined, undefined, undefined); - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); - }); - - it('should preserve pending Concierge intent when an authenticated onboarding route is replayed after refresh', () => { - setPendingConciergeDeepLink(); - - updatePendingConciergeDeepLinkForRoute(ROUTES.ONBOARDING_PURPOSE.route, true); - - expect(consumePendingConciergeDeepLink()).toBe(true); - }); - - it('should preserve a pending Concierge deep link when root is replayed during a browser reload', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - const restoreBrowserNavigation = mockBrowserReloadNavigation(); - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - setPendingConciergeDeepLink(); - - try { - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); - } finally { - restoreBrowserNavigation(); - } - }); - - it('should preserve a pending Concierge deep link when browser reload is only available from the legacy navigation API', () => { - const navigate = jest.spyOn(Navigation, 'navigate'); - const restoreBrowserNavigation = mockBrowserReloadNavigation(true); - mockIsReportTopmostSplitNavigator.mockReturnValue(true); - setPendingConciergeDeepLink(); - - try { - openReportFromDeepLink(`${CONST.NEW_EXPENSIFY_URL}/`, {}, false, REPORT_ID, undefined, undefined, undefined); - navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); - } finally { - restoreBrowserNavigation(); - } + expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME); }); }); From 18c668765f8f34c8ec716640f5a853e1813adf17 Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 7 Aug 2026 17:27:01 +0430 Subject: [PATCH 23/33] fix: honor /concierge deep link through all onboarding exit paths --- src/hooks/useAutoCreateSubmitWorkspace.ts | 5 ++++- src/hooks/useCompleteOnboarding.ts | 6 +++--- src/libs/navigateAfterOnboarding.ts | 21 ++++++++++++++++----- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index 7ed6cb46727a..c55ee50adcf1 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -122,7 +122,9 @@ function useAutoCreateSubmitWorkspace() { policyIDForNavigation = existingSubmitPolicyID; } - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); + // Pass conciergeReportID so the Submit workspace path can honor a pending + // /concierge deep-link intent the same way navigateAfterOnboarding does. + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID); }, [ currentUserEmail, @@ -144,6 +146,7 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, shouldUseNarrowLayout, conciergeChat, + conciergeReportID, ], ); diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 6addbe7952fd..77380e1e2304 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -129,6 +129,8 @@ function useCompleteOnboarding() { waitForUpcomingTransition: true, }); + // Pass rhpVariant directly — navigateAfterOnboardingWithMicrotaskQueue expects + // variantOverride as a plain OnboardingRHPVariant value, not a wrapper object. navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), @@ -137,9 +139,7 @@ function useCompleteOnboarding() { policyID, adminsChatReportID, (session?.email ?? '').includes('+'), - { - variantOverride: rhpVariant, - }, + rhpVariant, ); } catch (error) { Log.warn('[useCompleteOnboarding] Error completing onboarding', {error}); diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index d40f27c785ed..26ff7676b10f 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -74,6 +74,13 @@ function navigateAfterOnboarding( ) { setDisableDismissOnEscape(false); + // Honor a pending /concierge deep-link intent before any other navigation decision. + // The user explicitly opened /concierge before signing up, so Concierge takes priority. + if (consumePendingConciergeDeepLink()) { + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + return; + } + // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native @@ -100,8 +107,6 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - } else if (consumePendingConciergeDeepLink()) { - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); } else { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME); @@ -137,10 +142,16 @@ function navigateAfterOnboardingWithMicrotaskQueue( * After creating or joining a Submit workspace during onboarding, * navigate to Workspace > Categories with the side panel open so * the #admins room is visible in Concierge Anywhere. + * If the user arrived via a Concierge deep link, navigate to Concierge instead. */ -function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { setDisableDismissOnEscape(false); + if (consumePendingConciergeDeepLink()) { + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + return; + } + if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -155,10 +166,10 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } -function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false) { +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { Navigation.dismissModal(); Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout); + navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout, conciergeReportID); }); } From 375995fcf6ab1f704c744a82280a99d6daecc960 Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 7 Aug 2026 18:06:26 +0430 Subject: [PATCH 24/33] Fix KNIP regression for onboarding navigation helpers --- .../helpers/OnboardingNavigationUtils/index.native.ts | 6 +----- .../Navigation/helpers/OnboardingNavigationUtils/index.ts | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts index e3169554b8b1..76004caa155b 100644 --- a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts +++ b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts @@ -1,9 +1,5 @@ import Navigation from '@libs/Navigation/Navigation'; -function dismissOnboardingModalBeforeExit() { - Navigation.dismissModal(); -} - function resetOnboardingStackToRoot() {} -export {dismissOnboardingModalBeforeExit, resetOnboardingStackToRoot}; +export {resetOnboardingStackToRoot}; diff --git a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts index 77996cd34691..ba4edb9a4430 100644 --- a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts +++ b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts @@ -4,8 +4,6 @@ import NAVIGATORS from '@src/NAVIGATORS'; import {StackActions} from '@react-navigation/native'; -function dismissOnboardingModalBeforeExit() {} - /** * Pops the nested OnboardingModalNavigator stack back to its first route so useLinking * unwinds per-step browser history entries before onboarding completes and the modal unmounts. @@ -30,4 +28,4 @@ function resetOnboardingStackToRoot() { }); } -export {dismissOnboardingModalBeforeExit, resetOnboardingStackToRoot}; +export {resetOnboardingStackToRoot}; From 985c346534a4ad02519ef20d1b92f377a415a57a Mon Sep 17 00:00:00 2001 From: X Developer Date: Fri, 7 Aug 2026 18:32:06 +0430 Subject: [PATCH 25/33] Fix onboarding variant typing --- src/hooks/useAutoCreateTrackWorkspace.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 1f139d0b823c..55b9f4dc0a5f 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -141,7 +141,7 @@ function useAutoCreateTrackWorkspace() { newPolicyID, mergedAccountConciergeReportID, false, - {variantOverride: rhpVariant}, + rhpVariant, ); } }, From fe46d805a3b8e7d58685468ea7026a6ae15ca716 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 8 Aug 2026 11:36:13 +0430 Subject: [PATCH 26/33] Revert later PR changes and preserve only PR changes through f870138 --- src/hooks/useAutoCreateSubmitWorkspace.ts | 5 +---- src/hooks/useAutoCreateTrackWorkspace.ts | 2 +- src/hooks/useCompleteOnboarding.ts | 6 +++--- .../OnboardingNavigationUtils/index.native.ts | 6 +++++- .../OnboardingNavigationUtils/index.ts | 4 +++- src/libs/navigateAfterOnboarding.ts | 21 +++++-------------- 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index c55ee50adcf1..7ed6cb46727a 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -122,9 +122,7 @@ function useAutoCreateSubmitWorkspace() { policyIDForNavigation = existingSubmitPolicyID; } - // Pass conciergeReportID so the Submit workspace path can honor a pending - // /concierge deep-link intent the same way navigateAfterOnboarding does. - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); }, [ currentUserEmail, @@ -146,7 +144,6 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, shouldUseNarrowLayout, conciergeChat, - conciergeReportID, ], ); diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 55b9f4dc0a5f..1f139d0b823c 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -141,7 +141,7 @@ function useAutoCreateTrackWorkspace() { newPolicyID, mergedAccountConciergeReportID, false, - rhpVariant, + {variantOverride: rhpVariant}, ); } }, diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 77380e1e2304..6addbe7952fd 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -129,8 +129,6 @@ function useCompleteOnboarding() { waitForUpcomingTransition: true, }); - // Pass rhpVariant directly — navigateAfterOnboardingWithMicrotaskQueue expects - // variantOverride as a plain OnboardingRHPVariant value, not a wrapper object. navigateAfterOnboardingWithMicrotaskQueue( isSmallScreenWidth, isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS), @@ -139,7 +137,9 @@ function useCompleteOnboarding() { policyID, adminsChatReportID, (session?.email ?? '').includes('+'), - rhpVariant, + { + variantOverride: rhpVariant, + }, ); } catch (error) { Log.warn('[useCompleteOnboarding] Error completing onboarding', {error}); diff --git a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts index 76004caa155b..e3169554b8b1 100644 --- a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts +++ b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.native.ts @@ -1,5 +1,9 @@ import Navigation from '@libs/Navigation/Navigation'; +function dismissOnboardingModalBeforeExit() { + Navigation.dismissModal(); +} + function resetOnboardingStackToRoot() {} -export {resetOnboardingStackToRoot}; +export {dismissOnboardingModalBeforeExit, resetOnboardingStackToRoot}; diff --git a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts index ba4edb9a4430..77996cd34691 100644 --- a/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts +++ b/src/libs/Navigation/helpers/OnboardingNavigationUtils/index.ts @@ -4,6 +4,8 @@ import NAVIGATORS from '@src/NAVIGATORS'; import {StackActions} from '@react-navigation/native'; +function dismissOnboardingModalBeforeExit() {} + /** * Pops the nested OnboardingModalNavigator stack back to its first route so useLinking * unwinds per-step browser history entries before onboarding completes and the modal unmounts. @@ -28,4 +30,4 @@ function resetOnboardingStackToRoot() { }); } -export {resetOnboardingStackToRoot}; +export {dismissOnboardingModalBeforeExit, resetOnboardingStackToRoot}; diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 26ff7676b10f..d40f27c785ed 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -74,13 +74,6 @@ function navigateAfterOnboarding( ) { setDisableDismissOnEscape(false); - // Honor a pending /concierge deep-link intent before any other navigation decision. - // The user explicitly opened /concierge before signing up, so Concierge takes priority. - if (consumePendingConciergeDeepLink()) { - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); - return; - } - // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native @@ -107,6 +100,8 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } else if (consumePendingConciergeDeepLink()) { + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); } else { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME); @@ -142,16 +137,10 @@ function navigateAfterOnboardingWithMicrotaskQueue( * After creating or joining a Submit workspace during onboarding, * navigate to Workspace > Categories with the side panel open so * the #admins room is visible in Concierge Anywhere. - * If the user arrived via a Concierge deep link, navigate to Concierge instead. */ -function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { setDisableDismissOnEscape(false); - if (consumePendingConciergeDeepLink()) { - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); - return; - } - if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -166,10 +155,10 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } -function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false) { Navigation.dismissModal(); Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout, conciergeReportID); + navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout); }); } From ff8aa005bee8329753c9d37b4681190ce63fb670 Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 8 Aug 2026 12:32:19 +0430 Subject: [PATCH 27/33] fix: resolve Knip unused export in onboarding navigation --- src/libs/navigateAfterOnboarding.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index d40f27c785ed..e94cda4af266 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -13,6 +13,7 @@ import Onyx from 'react-native-onyx'; import {setDisableDismissOnEscape} from './actions/Modal'; import SidePanelActions from './actions/SidePanel'; import {setOnboardingRHPVariant} from './actions/Welcome'; +import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; import {consumePendingConciergeDeepLink} from './PendingConciergeDeepLink'; @@ -118,7 +119,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( shouldPreventOpenAdminRoom = false, variantOverride?: OnboardingRHPVariant | null, ) { - Navigation.dismissModal(); + dismissOnboardingModalBeforeExit(); Navigation.setNavigationActionToMicrotaskQueue(() => { navigateAfterOnboarding( isSmallScreenWidth, @@ -156,7 +157,7 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa } function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false) { - Navigation.dismissModal(); + dismissOnboardingModalBeforeExit(); Navigation.setNavigationActionToMicrotaskQueue(() => { navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout); }); From 5fc74dc228f1919daa1630516db2fbabb567cd2e Mon Sep 17 00:00:00 2001 From: X Developer Date: Sat, 8 Aug 2026 14:14:10 +0430 Subject: [PATCH 28/33] fix: prioritize pending Concierge deep link after onboarding --- src/hooks/useAutoCreateTrackWorkspace.ts | 2 +- src/hooks/useCompleteOnboarding.ts | 4 +--- src/libs/navigateAfterOnboarding.ts | 9 +++++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 1f139d0b823c..55b9f4dc0a5f 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -141,7 +141,7 @@ function useAutoCreateTrackWorkspace() { newPolicyID, mergedAccountConciergeReportID, false, - {variantOverride: rhpVariant}, + rhpVariant, ); } }, diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 6addbe7952fd..60f8cf3cc127 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -137,9 +137,7 @@ function useCompleteOnboarding() { policyID, adminsChatReportID, (session?.email ?? '').includes('+'), - { - variantOverride: rhpVariant, - }, + rhpVariant, ); } catch (error) { Log.warn('[useCompleteOnboarding] Error completing onboarding', {error}); diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index e94cda4af266..40460ea26bed 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -73,6 +73,13 @@ function navigateAfterOnboarding( shouldPreventOpenAdminRoom = false, variantOverride?: OnboardingRHPVariant | null, ) { + // A pending /concierge signup deep link should win before onboarding variants or workspace/admin fallbacks choose their standard destinations. + if (consumePendingConciergeDeepLink()) { + setDisableDismissOnEscape(false); + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + return; + } + setDisableDismissOnEscape(false); // On mobile (small screen), Track workspace admins with the trackExpensesWithConcierge variant @@ -101,8 +108,6 @@ function navigateAfterOnboarding( ); if (reportID) { Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - } else if (consumePendingConciergeDeepLink()) { - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); } else { // Navigate to home to trigger guard evaluation Navigation.navigate(ROUTES.HOME); From 501bac950d68c895a7d43f31ffba80815eee862a Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 10 Aug 2026 19:21:17 +0430 Subject: [PATCH 29/33] fix: restore secure link handling in deep links --- src/libs/actions/Link.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index e39bd54ade0a..9a3688ed680f 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -528,6 +528,30 @@ function openReportFromDeepLink( // Navigate to the report after sign-in/sign-up. waitForUserSignIn().then(() => { + // A Submit-via-PDF secure access link must reach the report regardless of onboarding status: the report screen + // is where JoinReportViaSecureLink runs, and onboarding is suppressed for secure-link visitors. The generic + // handling below intentionally drops deep links for users who still need to onboard, so branch out first. + if (Url.hasSecureLinkKey(route)) { + Navigation.waitForProtectedRoutes().then(() => { + // Secure links grant workspace + report access to a real account via JoinReportViaSecureLink, so an + // anonymous session can never fulfill them even though report routes are otherwise anonymous-accessible + // (canAnonymousUserAccessRoute would allow it). Force a real sign-in first; the deep link is re-processed + // after sign-in. Without this the user lands on /r/:id?secureKey with no join, stuck loading/404. + if (isAnonymousUser()) { + signOutAndRedirectToSignIn(true); + return; + } + // On cold launch the report is already the initial route; navigating again would stack a duplicate + // that renders "not found" until the join grants access. Only navigate when we're not already there. + if (Navigation.getTopmostReportId() === reportID) { + return; + } + const secureKey = new URLSearchParams(route.split('?').at(1) ?? '').get('secureKey') ?? undefined; + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, undefined, secureKey), {waitForTransition: true}); + }); + return; + } + // `false` when the user still had to onboard as this deep link was captured (fresh sign-up, or a // stale react-native-web URL); honoring it after onboarding flashes the "Not here" page (#91437). let initialHasCompletedGuidedSetupFlow: boolean | undefined; From 824126c759b1e9e7a82c6de672c9050d76d1275b Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 10 Aug 2026 19:44:42 +0430 Subject: [PATCH 30/33] fix: preserve Concierge deep link through onboarding --- src/hooks/useAutoCreateTrackWorkspace.ts | 2 +- src/hooks/useCompleteOnboarding.ts | 4 +- src/libs/actions/SignInRedirect.ts | 8 +++- src/libs/navigateAfterOnboarding.ts | 26 ++++++---- tests/unit/navigateAfterOnboardingTest.ts | 58 ++++++++++++++++++----- 5 files changed, 74 insertions(+), 24 deletions(-) diff --git a/src/hooks/useAutoCreateTrackWorkspace.ts b/src/hooks/useAutoCreateTrackWorkspace.ts index 55b9f4dc0a5f..1f139d0b823c 100644 --- a/src/hooks/useAutoCreateTrackWorkspace.ts +++ b/src/hooks/useAutoCreateTrackWorkspace.ts @@ -141,7 +141,7 @@ function useAutoCreateTrackWorkspace() { newPolicyID, mergedAccountConciergeReportID, false, - rhpVariant, + {variantOverride: rhpVariant}, ); } }, diff --git a/src/hooks/useCompleteOnboarding.ts b/src/hooks/useCompleteOnboarding.ts index 60f8cf3cc127..6addbe7952fd 100644 --- a/src/hooks/useCompleteOnboarding.ts +++ b/src/hooks/useCompleteOnboarding.ts @@ -137,7 +137,9 @@ function useCompleteOnboarding() { policyID, adminsChatReportID, (session?.email ?? '').includes('+'), - rhpVariant, + { + variantOverride: rhpVariant, + }, ); } catch (error) { Log.warn('[useCompleteOnboarding] Error completing onboarding', {error}); diff --git a/src/libs/actions/SignInRedirect.ts b/src/libs/actions/SignInRedirect.ts index 2ec1643244bc..f7dccaf20d44 100644 --- a/src/libs/actions/SignInRedirect.ts +++ b/src/libs/actions/SignInRedirect.ts @@ -2,6 +2,7 @@ import {getMicroSecondOnyxErrorWithMessage} from '@libs/ErrorUtils'; import {clearSessionStorage} from '@libs/Navigation/helpers/lastVisitedTabPathUtils'; import {getIsOffline} from '@libs/NetworkState'; import {clearPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; +import clearPrefetchOnAppStart from '@libs/Prefetch/clearPrefetchOnAppStart'; import CONFIG from '@src/CONFIG'; import type {OnyxKey} from '@src/ONYXKEYS'; @@ -78,7 +79,7 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: keysToPreserve.push(ONYXKEYS.NETWORK); } - // When the user is in the middle of a 2FA sign-in flow (they've entered their magic code but not yet completed + // When the user is in the middle of a 2FA sign-in flow (they've entered their validateCode but not yet completed // 2FA), we want to preserve their credentials and account state so that after a page refresh they are still // prompted to enter their 2FA code rather than being sent back to the initial sign-in page. const isIncompleteSignIn = !currentSessionAuthToken && !!currentCredentialsValidateCode; @@ -95,7 +96,10 @@ function clearStorageAndRedirect(errorMessage?: string, isSAMLReauthentication?: Onyx.merge(ONYXKEYS.ACCOUNT, {isLoading: true}); } - return Onyx.clear(keysToPreserve).then(() => { + return Onyx.clear(keysToPreserve).then(async () => { + // Requests may be processed while sign-out is in progress. Clear again after credentials have been removed so none of those requests remain queued for the next startup. + await clearPrefetchOnAppStart(); + if (CONFIG.IS_HYBRID_APP) { resetSignInFlow(); HybridAppModule.signOutFromOldDot(); diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 40460ea26bed..f6658131c546 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -13,6 +13,7 @@ import Onyx from 'react-native-onyx'; import {setDisableDismissOnEscape} from './actions/Modal'; import SidePanelActions from './actions/SidePanel'; import {setOnboardingRHPVariant} from './actions/Welcome'; +import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostSplitNavigator'; import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; @@ -27,6 +28,11 @@ Onyx.connectWithoutView({ }, }); +type NavigateAfterOnboardingOptions = { + afterTransition?: () => void; + variantOverride?: OnboardingRHPVariant | null; +}; + /** * Determines the report ID to navigate to after onboarding for control variant or ineligible users. * On large screens, navigates to the admins chat if available. On small screens, finds the last @@ -71,12 +77,13 @@ function navigateAfterOnboarding( onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, - variantOverride?: OnboardingRHPVariant | null, + options?: NavigateAfterOnboardingOptions, ) { + const navigationOptions = options?.afterTransition ? {afterTransition: options.afterTransition} : undefined; // A pending /concierge signup deep link should win before onboarding variants or workspace/admin fallbacks choose their standard destinations. if (consumePendingConciergeDeepLink()) { setDisableDismissOnEscape(false); - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route)); + Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route), navigationOptions); return; } @@ -86,14 +93,15 @@ function navigateAfterOnboarding( // should navigate directly to the Concierge DM (which contains onboarding tasks). // This check is outside shouldOpenRHPVariant because that function returns false on native // (Side Panel doesn't exist on native), but we still need to navigate to Concierge on mobile. + const variantOverride = options?.variantOverride; const variant = variantOverride ?? onboardingRHPVariant; if (isSmallScreenWidth && variant === CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID), navigationOptions); return; } if (shouldOpenRHPVariant(variantOverride)) { - handleRHPVariantNavigation(onboardingPolicyID, variantOverride); + handleRHPVariantNavigation(onboardingPolicyID, variantOverride, navigationOptions); return; } @@ -107,10 +115,10 @@ function navigateAfterOnboarding( shouldPreventOpenAdminRoom, ); if (reportID) { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - } else { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID), navigationOptions); + } else if (!isReportTopmostSplitNavigator()) { // Navigate to home to trigger guard evaluation - Navigation.navigate(ROUTES.HOME); + Navigation.navigate(ROUTES.HOME, navigationOptions); } } @@ -122,7 +130,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( onboardingPolicyID?: string, onboardingAdminsChatReportID?: string, shouldPreventOpenAdminRoom = false, - variantOverride?: OnboardingRHPVariant | null, + options?: NavigateAfterOnboardingOptions, ) { dismissOnboardingModalBeforeExit(); Navigation.setNavigationActionToMicrotaskQueue(() => { @@ -134,7 +142,7 @@ function navigateAfterOnboardingWithMicrotaskQueue( onboardingPolicyID, onboardingAdminsChatReportID, shouldPreventOpenAdminRoom, - variantOverride, + options, ); }); } diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index e3b83d7e3ee1..2ae507613dc7 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -22,6 +22,27 @@ const REPORT_ID = '3'; const USER_ID = '4'; const mockFindLastAccessedReport = jest.fn, Parameters>(); const mockShouldOpenOnAdminRoom = jest.fn(); +const mockIsReportTopmostSplitNavigator = jest.fn(() => false); + +jest.mock('@expensify/react-native-hybrid-app', () => ({ + __esModule: true, + default: { + isHybridApp: jest.fn(() => false), + shouldUseStaging: jest.fn(), + closeReactNativeApp: jest.fn(), + completeOnboarding: jest.fn(), + switchAccount: jest.fn(), + sendAuthToken: jest.fn(), + getHybridAppSettings: jest.fn(() => Promise.resolve(null)), + getInitialURL: jest.fn(() => Promise.resolve(null)), + onURLListenerAdded: jest.fn(), + signInToOldDot: jest.fn(), + signOutFromOldDot: jest.fn(), + startSignOut: jest.fn(), + cancelSignOut: jest.fn(), + clearOldDotAfterSignOut: jest.fn(), + }, +})); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -54,6 +75,11 @@ jest.mock('@libs/Navigation/helpers/shouldOpenOnAdminRoom', () => ({ default: () => mockShouldOpenOnAdminRoom() as boolean, })); +jest.mock('@libs/Navigation/helpers/isReportTopmostSplitNavigator', () => ({ + __esModule: true, + default: () => mockIsReportTopmostSplitNavigator(), +})); + describe('navigateAfterOnboarding', () => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); @@ -63,6 +89,7 @@ describe('navigateAfterOnboarding', () => { beforeEach(async () => { jest.clearAllMocks(); + mockIsReportTopmostSplitNavigator.mockReturnValue(false); clearPendingConciergeDeepLink(); return Onyx.clear(); }); @@ -72,15 +99,24 @@ describe('navigateAfterOnboarding', () => { const testSession = {email: 'realaccount@gmail.com'}; navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); - it('should not navigate to the admin room report if onboardingAdminsChatReportID is not provided on larger screens', () => { + it('should navigate to home if onboardingAdminsChatReportID is not provided on larger screens and no report is topmost', () => { const navigate = jest.spyOn(Navigation, 'navigate'); + navigateAfterOnboarding(false, true, '', {}, undefined, undefined); // Without an admins chat report, we fall back to HOME to trigger guard evaluation instead of opening a report. expect(navigate).not.toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); - expect(navigate).toHaveBeenCalledWith(ROUTES.HOME); + expect(navigate).toHaveBeenCalledWith(ROUTES.HOME, undefined); + }); + + it('should preserve the topmost report if onboardingAdminsChatReportID is not provided on larger screens', () => { + const navigate = jest.spyOn(Navigation, 'navigate'); + mockIsReportTopmostSplitNavigator.mockReturnValue(true); + + navigateAfterOnboarding(false, true, '', {}, undefined, undefined); + expect(navigate).not.toHaveBeenCalled(); }); it('should not navigate to last accessed report if it is a concierge chat on small screens', async () => { @@ -128,7 +164,7 @@ describe('navigateAfterOnboarding', () => { mockShouldOpenOnAdminRoom.mockReturnValue(true); navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); }); it('should pass reportNameValuePairs when looking up last accessed report', () => { @@ -149,13 +185,13 @@ describe('navigateAfterOnboarding', () => { const testSession = {email: 'test+account@gmail.com'}; navigateAfterOnboarding(true, true, '', {}, ONBOARDING_POLICY_ID, ONBOARDING_ADMINS_CHAT_REPORT_ID, (testSession?.email ?? '').includes('+')); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); }); it('should navigate to the admin room when the inboxAdminsBespoke variant is assigned', () => { const navigate = jest.spyOn(Navigation, 'navigate'); - navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID)); + navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, {variantOverride: CONST.ONBOARDING_RHP_VARIANT.INBOX_ADMINS_BESPOKE}); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); it('should navigate to Concierge instead of Home when a pending Concierge deep link is available', () => { @@ -164,7 +200,7 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); @@ -174,7 +210,7 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(false, true, '', {}, undefined, undefined); - expect(navigate).toHaveBeenCalledWith(ROUTES.CONCIERGE); + expect(navigate).toHaveBeenCalledWith(ROUTES.CONCIERGE, undefined); expect(navigate).not.toHaveBeenCalledWith(ROUTES.HOME); }); @@ -185,7 +221,7 @@ describe('navigateAfterOnboarding', () => { navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); navigateAfterOnboarding(false, true, REPORT_ID, {}, undefined, undefined); - expect(navigate).toHaveBeenNthCalledWith(1, ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID)); - expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME); + expect(navigate).toHaveBeenNthCalledWith(1, ROUTES.REPORT_WITH_ID.getRoute(REPORT_ID), undefined); + expect(navigate).toHaveBeenNthCalledWith(2, ROUTES.HOME, undefined); }); }); From 75ddd25cfa96f6f3b2b972f4e92b37ecbf21d75e Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 17 Aug 2026 10:28:42 +0430 Subject: [PATCH 31/33] Fix Concierge onboarding route type safety --- src/libs/navigateAfterOnboarding.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index b83d7a3d9dce..3c59f1af7cb3 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -3,6 +3,7 @@ import {handleRHPVariantNavigation, shouldOpenRHPVariant} from '@components/Side import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import type {OnboardingRHPVariant, ReportNameValuePairs} from '@src/types/onyx'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; @@ -83,7 +84,8 @@ function navigateAfterOnboarding( // A pending /concierge signup deep link should win before onboarding variants or workspace/admin fallbacks choose their standard destinations. if (consumePendingConciergeDeepLink()) { setDisableDismissOnEscape(false); - Navigation.navigate(conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : (ROUTES.CONCIERGE as Route), navigationOptions); + const pendingConciergeRoute: Route = conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : ROUTES.CONCIERGE; + Navigation.navigate(pendingConciergeRoute, navigationOptions); return; } From 8c7823fee3406311d32dbb5f3dae17faed3728bb Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 17 Aug 2026 11:20:06 +0430 Subject: [PATCH 32/33] Preserve Concierge deep link after Submit onboarding --- src/hooks/useAutoCreateSubmitWorkspace.ts | 3 ++- src/libs/navigateAfterOnboarding.ts | 12 +++++++++--- .../BaseOnboardingWorkspaces.tsx | 2 +- tests/unit/libs/navigateAfterOnboarding.test.ts | 14 ++++++++++++++ 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index c21193acf5fd..fcb79441c309 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -126,7 +126,7 @@ function useAutoCreateSubmitWorkspace() { policyIDForNavigation = existingSubmitPolicyID; } - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID); }, [ currentUserEmail, @@ -148,6 +148,7 @@ function useAutoCreateSubmitWorkspace() { hasActiveAdminPolicies, shouldUseNarrowLayout, conciergeChat, + conciergeReportID, ], ); diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index 3c59f1af7cb3..c2c6c927513a 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -153,9 +153,15 @@ function navigateAfterOnboardingWithMicrotaskQueue( * After creating or joining a Submit workspace during onboarding, navigate to Spend > Expenses * with the side panel open so the #admins room is visible in Concierge Anywhere. */ -function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { +function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { setDisableDismissOnEscape(false); + if (consumePendingConciergeDeepLink()) { + const pendingConciergeRoute: Route = conciergeReportID ? ROUTES.REPORT_WITH_ID.getRoute(conciergeReportID) : ROUTES.CONCIERGE; + Navigation.navigate(pendingConciergeRoute); + return; + } + if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -166,10 +172,10 @@ function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNa SidePanelActions.openSidePanel(!shouldUseNarrowLayout); } -function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false) { +function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: string, shouldUseNarrowLayout = false, conciergeReportID?: string) { dismissOnboardingModalBeforeExit(); Navigation.setNavigationActionToMicrotaskQueue(() => { - navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout); + navigateToSubmitWorkspaceAfterOnboarding(policyID, shouldUseNarrowLayout, conciergeReportID); }); } diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index e4e0c0b32bf9..b4cab0134fcd 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -104,7 +104,7 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingPolicyID(policy.policyID); if (shouldUseSubmitFlow) { - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout); + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout, conciergeReportID); return; } diff --git a/tests/unit/libs/navigateAfterOnboarding.test.ts b/tests/unit/libs/navigateAfterOnboarding.test.ts index 864b93742c1c..5c055200c206 100644 --- a/tests/unit/libs/navigateAfterOnboarding.test.ts +++ b/tests/unit/libs/navigateAfterOnboarding.test.ts @@ -2,6 +2,7 @@ import SidePanelActions from '@libs/actions/SidePanel'; import {setOnboardingRHPVariant} from '@libs/actions/Welcome'; import {navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; +import {clearPendingConciergeDeepLink, setPendingConciergeDeepLink} from '@libs/PendingConciergeDeepLink'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; import CONST from '@src/CONST'; @@ -34,6 +35,7 @@ const navigationMock = jest.mocked(Navigation); describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { beforeEach(() => { jest.clearAllMocks(); + clearPendingConciergeDeepLink(); }); it('navigates to HOME without opening the side panel when policyID is missing', () => { @@ -67,4 +69,16 @@ describe('navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue', () => { expect(setOnboardingRHPVariant).toHaveBeenCalledWith(CONST.ONBOARDING_RHP_VARIANT.RHP_ADMINS_ROOM); expect(SidePanelActions.openSidePanel).toHaveBeenCalledWith(false); }); + + it('navigates to Concierge when Submit onboarding started from a pending Concierge deep link', () => { + setPendingConciergeDeepLink(); + + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue('test-policy-id', false, 'concierge-report-id'); + + expect(navigationMock.dismissModal).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledTimes(1); + expect(navigationMock.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('concierge-report-id')); + expect(setOnboardingRHPVariant).not.toHaveBeenCalled(); + expect(SidePanelActions.openSidePanel).not.toHaveBeenCalled(); + }); }); From c74116397edfab31b04c60f6dfd383b473317cac Mon Sep 17 00:00:00 2001 From: X Developer Date: Mon, 17 Aug 2026 11:32:27 +0430 Subject: [PATCH 33/33] Avoid undefined Concierge argument in Submit onboarding navigation --- src/hooks/useAutoCreateSubmitWorkspace.ts | 6 +++++- src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/hooks/useAutoCreateSubmitWorkspace.ts b/src/hooks/useAutoCreateSubmitWorkspace.ts index fcb79441c309..4494c5240a8c 100644 --- a/src/hooks/useAutoCreateSubmitWorkspace.ts +++ b/src/hooks/useAutoCreateSubmitWorkspace.ts @@ -126,7 +126,11 @@ function useAutoCreateSubmitWorkspace() { policyIDForNavigation = existingSubmitPolicyID; } - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID); + if (conciergeReportID) { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID); + } else { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout); + } }, [ currentUserEmail, diff --git a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx index b4cab0134fcd..758c3771badb 100644 --- a/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx +++ b/src/pages/OnboardingWorkspaces/BaseOnboardingWorkspaces.tsx @@ -104,7 +104,11 @@ function BaseOnboardingWorkspaces({route, shouldUseNativeStyles}: BaseOnboarding setOnboardingPolicyID(policy.policyID); if (shouldUseSubmitFlow) { - navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout, conciergeReportID); + if (conciergeReportID) { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout, conciergeReportID); + } else { + navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policy.policyID, shouldUseNarrowLayout); + } return; }