From 8524633a965ee52d72af8b260fc5f3e52f5f7806 Mon Sep 17 00:00:00 2001 From: Mukher Date: Sat, 10 Oct 2026 17:45:18 +0300 Subject: [PATCH 1/8] Reapply "Open a deep link captured before onboarding instead of dropping it" This reverts commit 4e385e2fcb0, bringing back #101558 unchanged. The regressions that caused the revert are fixed in the next commit. --- src/libs/actions/Link.ts | 107 ++++++++++++++-------- src/libs/navigateAfterOnboarding.ts | 41 ++++++++- tests/unit/navigateAfterOnboardingTest.ts | 75 ++++++++++++++- 3 files changed, 185 insertions(+), 38 deletions(-) diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index d5a11aa7765a..6a1075c6f3cb 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -6,6 +6,7 @@ import buildOldDotURL from '@libs/buildOldDotURL'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; import Log from '@libs/Log'; +import {setDeepLinkToOpenAfterOnboarding} from '@libs/navigateAfterOnboarding'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; @@ -565,30 +566,6 @@ function openReportFromDeepLink( const state = navigationRef.getRootState(); const currentFocusedRoute = findFocusedRoute(state); - if (isOnboardingFlowName(currentFocusedRoute?.name)) { - setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); - return; - } - - if (shouldSkipDeepLinkNavigation(route)) { - return; - } - - if (currentFocusedRoute?.name !== SCREENS.HOME && route === ROUTES.HOME) { - return; - } - - // Drop a deep link captured before onboarding finished: navigateAfterOnboarding owns the - // post-onboarding destination and overrides it anyway, so honoring it only risks the flash (#91437). - if (initialHasCompletedGuidedSetupFlow === false) { - return; - } - - // Navigation for signed users is handled by react-navigation. - if (isAuthenticated) { - return; - } - const navigateHandler = (reportParam?: OnyxEntry) => { // Skip if the user already is in the deeplinked route. const deeplinkRoute = route as Route; @@ -615,22 +592,80 @@ function openReportFromDeepLink( const forceReplace = navigationRef.getRootState().routes.at(-1)?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; Navigation.navigate(deeplinkRoute, {forceReplace, waitForTransition: true}); }; - // If we log with deeplink with reportID and data for this report is not available yet, - // then we will wait for Onyx to completely merge data from OpenReport API with OpenApp API in AuthScreens - if (reportID && !isAuthenticated && !reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) { - const reportConnection = Onyx.connectWithoutView({ - key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, - // eslint-disable-next-line rulesdir/prefer-early-return - callback: (report) => { - if (report?.errorFields?.notFound || report?.reportID || (report === undefined && CONST.REGEX.NON_NUMERIC.test(reportID))) { + + const openDeepLink = () => { + // OpenApp and OpenReport may not have written this report yet, and navigateHandler would read the missing + // report as "does not exist" and send the user to the last accessed report or Concierge instead. + if (reportID && !isAuthenticated && !reports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportID) { + const reportConnection = Onyx.connectWithoutView({ + key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, + callback: (report) => { + if (!report?.errorFields?.notFound && !report?.reportID && !(report === undefined && CONST.REGEX.NON_NUMERIC.test(reportID))) { + return; + } Onyx.disconnect(reportConnection); navigateHandler(report); - } - }, - }); - } else { + }, + }); + return; + } + navigateHandler(); + }; + + // Shared with the onboarding branch below, which sits above this check and would otherwise let a parked link + // replay past it. + const shouldDropDeepLink = () => shouldSkipDeepLinkNavigation(route) || (currentFocusedRoute?.name !== SCREENS.HOME && route === ROUTES.HOME); + + // Opening the link while onboarding still owns the screen flashes the "Not here" page, so hand it to + // navigateAfterOnboarding, which runs after the modal is gone and would otherwise lose it. + const deferUntilAfterOnboarding = () => { + if (initialHasCompletedGuidedSetupFlow !== false || shouldDropDeepLink()) { + return false; + } + + setDeepLinkToOpenAfterOnboarding(() => { + // A new account can finish onboarding before OpenApp does. Opening the link then shows its loading + // skeleton, so wait until report data has loaded. This runs in navigation code, outside React, so + // useOnyx is not available. + const loadingConnection = Onyx.connectWithoutView({ + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + callback: (isLoadingReportData) => { + if (isLoadingReportData) { + return; + } + Onyx.disconnect(loadingConnection); + openDeepLink(); + }, + }); + return true; + }); + return true; + }; + + if (isOnboardingFlowName(currentFocusedRoute?.name)) { + if (deferUntilAfterOnboarding()) { + return; + } + + setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); + return; } + + if (shouldDropDeepLink()) { + return; + } + + // Navigation for signed users is handled by react-navigation. + if (isAuthenticated) { + return; + } + + if (deferUntilAfterOnboarding()) { + return; + } + + openDeepLink(); }; if (hasCompletedGuidedSetupFlowSelector(val) || isAnonymousUser()) { diff --git a/src/libs/navigateAfterOnboarding.ts b/src/libs/navigateAfterOnboarding.ts index e3bde32c60d2..61de369f5c51 100644 --- a/src/libs/navigateAfterOnboarding.ts +++ b/src/libs/navigateAfterOnboarding.ts @@ -12,12 +12,14 @@ import Onyx from 'react-native-onyx'; import {setDisableDismissOnEscape} from './actions/Modal'; import SidePanelActions from './actions/SidePanel'; import {setOnboardingRHPVariant} from './actions/Welcome'; +import Log from './Log'; import isReportTopmostSplitNavigator from './Navigation/helpers/isReportTopmostSplitNavigator'; import {dismissOnboardingModalBeforeExit} from './Navigation/helpers/OnboardingNavigationUtils'; import shouldOpenOnAdminRoom from './Navigation/helpers/shouldOpenOnAdminRoom'; import Navigation from './Navigation/Navigation'; import {findLastAccessedReport} from './ReportUtils'; import {buildCannedSearchQuery} from './SearchQueryUtils'; +import {registerSessionCleanupCallback} from './SessionCleanup'; let onboardingRHPVariant: OnyxEntry; Onyx.connectWithoutView({ @@ -27,6 +29,34 @@ Onyx.connectWithoutView({ }, }); +/** Parked by openReportFromDeepLink() while onboarding owns the screen. In memory rather than Onyx so a stale destination cannot replay after a reload or for the next account on the device. */ +let openDeepLinkAfterOnboarding: (() => boolean) | undefined; + +/** The callback returns whether it opened the route. Returning false lets the caller pick the default destination. */ +function setDeepLinkToOpenAfterOnboarding(callback: () => boolean) { + openDeepLinkAfterOnboarding = callback; +} + +function consumeDeepLinkToOpenAfterOnboarding(): boolean { + if (!openDeepLinkAfterOnboarding) { + return false; + } + + const openDeepLink = openDeepLinkAfterOnboarding; + openDeepLinkAfterOnboarding = undefined; + try { + return openDeepLink(); + } catch (error) { + // The onboarding modal is already dismissed, so a failing replay must not take the default destination with it. + Log.alert('[navigateAfterOnboarding] Parked deep link failed to open', {error}); + return false; + } +} + +registerSessionCleanupCallback(() => { + openDeepLinkAfterOnboarding = undefined; +}); + type NavigateAfterOnboardingOptions = { afterTransition?: () => void; variantOverride?: OnboardingRHPVariant | null; @@ -86,6 +116,11 @@ function navigateAfterOnboarding( ) { setDisableDismissOnEscape(false); + // A route the user asked for before signing up outranks every default below, including the Side Panel variant. + if (consumeDeepLinkToOpenAfterOnboarding()) { + 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 @@ -162,6 +197,10 @@ function navigateAfterOnboardingWithMicrotaskQueue( function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { setDisableDismissOnEscape(false); + if (consumeDeepLinkToOpenAfterOnboarding()) { + return; + } + if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -179,4 +218,4 @@ function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: s }); } -export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, setDeepLinkToOpenAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 1e342d33cd0e..8c6773c88af7 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,6 +1,7 @@ -import {navigateAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboarding, setDeepLinkToOpenAfterOnboarding} from '@libs/navigateAfterOnboarding'; import Navigation from '@libs/Navigation/Navigation'; import type * as ReportUtils from '@libs/ReportUtils'; +import {runSessionCleanupCallbacks} from '@libs/SessionCleanup'; import initOnyxDerivedValues from '@userActions/OnyxDerived'; import SidePanelActions from '@userActions/SidePanel'; @@ -91,6 +92,11 @@ describe('navigateAfterOnboarding', () => { return Onyx.clear(); }); + afterEach(() => { + // The parked link lives in a module-level variable, so a test that parks without consuming would leak into the next one. + runSessionCleanupCallbacks(); + }); + it('should navigate to the admin room report if onboardingAdminsChatReportID is provided', () => { const navigate = jest.spyOn(Navigation, 'navigate'); const testSession = {email: 'realaccount@gmail.com'}; @@ -191,6 +197,73 @@ describe('navigateAfterOnboarding', () => { expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); + it('should open a deep link parked before onboarding instead of the default destination', () => { + // Given a deep link that was captured before the account finished onboarding + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => true); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + // When onboarding finishes + navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + // Then the parked link wins and no default destination is used + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should open a parked deep link even when the trackExpensesWithConcierge variant would send the user to Concierge', () => { + // Given an account assigned the variant whose branch returns before the default destination is chosen + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => true); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + navigateAfterOnboarding(true, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, { + variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE, + }); + + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('should still use the default destination when the parked deep link throws', () => { + // Given a parked link that fails to open, after the onboarding modal is already dismissed + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => { + throw new Error('boom'); + }); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + }); + + it('should fall back to the default destination when the parked deep link cannot be opened', () => { + // Given a parked link that reports it did not navigate + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => false); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + }); + + it('should only replay a parked deep link once', () => { + // Given a link parked once and two onboarding exits + const navigate = jest.spyOn(Navigation, 'navigate'); + const openDeepLink = jest.fn(() => true); + setDeepLinkToOpenAfterOnboarding(openDeepLink); + + navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + + expect(openDeepLink).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); + }); + it('should land on Home instead of the admin room for the homePageNoRHP variant', () => { // Given an admin whose #admins room exists, which the control and inboxAdminsBespoke arms open after onboarding const navigate = jest.spyOn(Navigation, 'navigate'); From bbb8e80e48d0d909c195240e354a20e58ffda3a7 Mon Sep 17 00:00:00 2001 From: Mukher Date: Sat, 10 Oct 2026 17:45:28 +0300 Subject: [PATCH 2/8] Keep the Concierge page from flashing or closing onboarding A parked /concierge link was replayed by navigating to the concierge route, a URL entry page that shows a skeleton and redirects itself. On wide layouts it slid in as a panel for close to a second after onboarding. The replay now opens the chat with navigateToConciergeChat, as the rest of the app does. When the app starts on /concierge for an account that has not finished onboarding, ConciergePage mounts under the onboarding modal, and its redirect dismisses the top modal, closing onboarding early. It now waits until guided setup is complete. --- src/libs/actions/Link.ts | 6 ++ src/pages/ConciergePage.tsx | 11 +++- tests/ui/ConciergePageTest.tsx | 115 +++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 tests/ui/ConciergePageTest.tsx diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 6a1075c6f3cb..4c97a1dce6da 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -635,6 +635,12 @@ function openReportFromDeepLink( return; } Onyx.disconnect(loadingConnection); + // The concierge route is a URL entry page that redirects itself, and on wide layouts it slides in as a + // panel first, so open the chat the way the rest of the app does instead. + if (route === ROUTES.CONCIERGE) { + navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); + return; + } openDeepLink(); }, }); diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 6e71630490af..8b76e53c5282 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -10,12 +10,13 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {navigateToConciergeChat} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {useFocusEffect} from '@react-navigation/native'; -import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@selectors/Onboarding'; import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; @@ -34,12 +35,16 @@ function ConciergePage() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); + const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); + // Onboarding opens on top of this page for a new account, and the redirect below dismisses the top modal, so it + // would close onboarding before it is finished. The link is opened once onboarding completes instead. + const isWaitingForOnboarding = !CONFIG.SKIP_ONBOARDING && hasCompletedGuidedSetupFlow === false; useFocusEffect( useCallback(() => { if (session && 'authToken' in session) { Navigation.isNavigationReady().then(() => { - if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData) { + if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData || isWaitingForOnboarding) { return; } @@ -56,7 +61,7 @@ function ConciergePage() { } else { Navigation.navigate(ROUTES.INBOX); } - }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, conciergePersonalDetails]), + }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, conciergePersonalDetails, isWaitingForOnboarding]), ); useEffect(() => { diff --git a/tests/ui/ConciergePageTest.tsx b/tests/ui/ConciergePageTest.tsx new file mode 100644 index 000000000000..2893b0fd3908 --- /dev/null +++ b/tests/ui/ConciergePageTest.tsx @@ -0,0 +1,115 @@ +import {act, render} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import {navigateToConciergeChat} from '@libs/actions/Report'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; + +import ConciergePage from '@pages/ConciergePage'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@libs/actions/Report', () => ({ + navigateToConciergeChat: jest.fn(() => Promise.resolve()), +})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + isNavigationReady: jest.fn(() => Promise.resolve()), + getActiveRoute: jest.fn(() => ''), + getActiveRouteWithoutParams: jest.fn(() => ''), +})); + +const mockNavigateToConciergeChat = jest.mocked(navigateToConciergeChat); + +const ACCOUNT_ID = 12345; +const CONCIERGE_REPORT_ID = '98765'; + +type ConciergeStackParamList = { + [SCREENS.CONCIERGE]: undefined; +}; + +const Stack = createPlatformStackNavigator(); + +function renderPage() { + return render( + + + + + + + + + , + ); +} + +async function seedSignedInAccount(hasCompletedGuidedSetupFlow: boolean) { + await Onyx.set(ONYXKEYS.SESSION, {accountID: ACCOUNT_ID, authToken: 'token'}); + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + await Onyx.set(ONYXKEYS.CONCIERGE_REPORT_ID, CONCIERGE_REPORT_ID); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow}); +} + +describe('ConciergePage', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + it('opens the Concierge chat for an account that has finished onboarding', async () => { + // Given a signed-in account whose report data is loaded and onboarding is done + await act(async () => { + await seedSignedInAccount(true); + }); + + // When the page mounts + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it redirects to the chat, which is its whole job + expect(mockNavigateToConciergeChat).toHaveBeenCalledWith(expect.objectContaining({conciergeReportID: CONCIERGE_REPORT_ID, shouldDismissModal: true})); + }); + + it('waits for onboarding to finish before opening the Concierge chat', async () => { + // Given a new account that lands on this page while onboarding is still open on top of it + await act(async () => { + await seedSignedInAccount(false); + }); + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it does not redirect, because the redirect dismisses the top modal and would close onboarding + expect(mockNavigateToConciergeChat).not.toHaveBeenCalled(); + + // When onboarding completes + await act(async () => { + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + }); + await waitForBatchedUpdatesWithAct(); + + // Then the chat opens + expect(mockNavigateToConciergeChat).toHaveBeenCalledTimes(1); + }); +}); From cadaf11f7d2837c4d3a098fdae594d7af47e963d Mon Sep 17 00:00:00 2001 From: Mukher Date: Sun, 11 Oct 2026 09:44:06 +0300 Subject: [PATCH 3/8] Cover the deep link replay after onboarding in tests Tests openReportFromDeepLink's onboarding path: the link is parked instead of opened under onboarding, replayed only after report data loads, opened in the Concierge chat for /concierge, held until a linked report arrives, and dropped for links the app never replays. --- tests/unit/LinkOpenReportFromDeepLinkTest.ts | 188 +++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 tests/unit/LinkOpenReportFromDeepLinkTest.ts diff --git a/tests/unit/LinkOpenReportFromDeepLinkTest.ts b/tests/unit/LinkOpenReportFromDeepLinkTest.ts new file mode 100644 index 000000000000..47f864ff68df --- /dev/null +++ b/tests/unit/LinkOpenReportFromDeepLinkTest.ts @@ -0,0 +1,188 @@ +import {navigateToConciergeChat} from '@libs/actions/Report'; +import {setOnboardingErrorMessage} from '@libs/actions/Welcome'; +import {setDeepLinkToOpenAfterOnboarding} from '@libs/navigateAfterOnboarding'; +import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; + +import {openReportFromDeepLink} from '@src/libs/actions/Link'; +import NAVIGATORS from '@src/NAVIGATORS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationState} from '@react-navigation/native'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/Navigation/navigationRef', () => ({ + __esModule: true, + default: {getRootState: jest.fn()}, +})); +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + navigate: jest.fn(), + isActiveRoute: jest.fn(() => false), + getTopmostReportId: jest.fn(), + waitForProtectedRoutes: jest.fn(() => Promise.resolve()), + }, +})); +jest.mock('@libs/actions/Session', () => ({ + canAnonymousUserAccessRoute: jest.fn(() => true), + isAnonymousUser: jest.fn(() => false), + signOutAndRedirectToSignIn: jest.fn(), + waitForUserSignIn: jest.fn(() => Promise.resolve()), +})); +jest.mock('@libs/actions/Report', () => ({ + doneCheckingPublicRoom: jest.fn(), + navigateToConciergeChat: jest.fn(), + openReport: jest.fn(), +})); +jest.mock('@libs/actions/Welcome', () => ({ + setOnboardingErrorMessage: jest.fn(), +})); +jest.mock('@libs/navigateAfterOnboarding', () => ({ + setDeepLinkToOpenAfterOnboarding: jest.fn(), +})); + +const mockedNavigation = jest.mocked(Navigation); +const mockedNavigateToConciergeChat = jest.mocked(navigateToConciergeChat); +const mockedSetDeepLinkToOpenAfterOnboarding = jest.mocked(setDeepLinkToOpenAfterOnboarding); +const mockedNavigationRef = jest.mocked(navigationRef); +const mockedSetOnboardingErrorMessage = jest.mocked(setOnboardingErrorMessage); + +const APP_URL = 'https://new.expensify.com'; + +function focusOn(screenName: string) { + const state: NavigationState = { + stale: false, + type: 'stack', + key: 'root', + index: 0, + routeNames: [screenName], + routes: [{key: screenName, name: screenName}], + }; + mockedNavigationRef.getRootState.mockReturnValue(state); +} + +async function handleLinkCapturedBeforeOnboarding(route: string) { + // The link arrives while signed out, then the new account signs up and still has onboarding ahead of it + openReportFromDeepLink(`${APP_URL}/${route}`, {}, false, undefined, undefined, undefined, 0, {}, undefined); + await Onyx.set(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: false}); + await waitForBatchedUpdates(); + + // Completing onboarding marks it done before its screens close, which is when the link is handled + await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, {hasCompletedGuidedSetupFlow: true}); + await waitForBatchedUpdates(); +} + +function replayParkedLink() { + const openParkedLink = mockedSetDeepLinkToOpenAfterOnboarding.mock.calls.at(-1)?.[0]; + return openParkedLink?.(); +} + +describe('openReportFromDeepLink before onboarding', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await Onyx.clear(); + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, true); + }); + + it('parks the link instead of opening it under the onboarding modal', async () => { + // Given onboarding is on screen when the link is handled + focusOn(SCREENS.ONBOARDING.PURPOSE); + + // When a link captured before sign-up is processed + await handleLinkCapturedBeforeOnboarding(ROUTES.SETTINGS_PROFILE.route); + + // Then it is handed to the post-onboarding navigation rather than opened now, which would flash the "Not here" page + expect(mockedSetDeepLinkToOpenAfterOnboarding).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('opens a parked link only once report data has loaded', async () => { + // Given a link parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(ROUTES.SETTINGS_PROFILE.route); + + // When onboarding finishes while the new account's data is still loading + expect(replayParkedLink()).toBe(true); + await waitForBatchedUpdates(); + + // Then nothing opens yet, because the route would sit on its loading skeleton + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + + // When the data finishes loading + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + await waitForBatchedUpdates(); + + // Then the link opens + expect(mockedNavigation.navigate).toHaveBeenCalledWith(ROUTES.SETTINGS_PROFILE.route, expect.objectContaining({waitForTransition: true})); + }); + + it('opens a parked Concierge link in the chat instead of through the Concierge page', async () => { + // Given a Concierge link parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(ROUTES.CONCIERGE); + + // When onboarding finishes and the data has loaded + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + replayParkedLink(); + await waitForBatchedUpdates(); + + // Then the chat opens directly, since the Concierge page is a URL entry page that shows a skeleton and redirects itself + expect(mockedNavigateToConciergeChat).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('also parks the link when onboarding is no longer the focused screen', async () => { + // Given the onboarding screens already closed by the time the link is handled + focusOn(NAVIGATORS.TAB_NAVIGATOR); + + // When a link captured before sign-up is processed + await handleLinkCapturedBeforeOnboarding(ROUTES.SETTINGS_PROFILE.route); + + // Then it is still parked, because the account had to onboard when the link was captured + expect(mockedSetDeepLinkToOpenAfterOnboarding).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + + it('opens a parked report link once the report itself has arrived', async () => { + // Given a link to a report the new account has not loaded yet, parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(ROUTES.REPORT_WITH_ID.getRoute('123')); + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + + // When onboarding finishes before the report is in Onyx + replayParkedLink(); + await waitForBatchedUpdates(); + + // Then it waits, because a missing report would be treated as not found and sent elsewhere + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + + // When the report arrives + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}123`, {reportID: '123'}); + await waitForBatchedUpdates(); + + // Then the report opens + expect(mockedNavigation.navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute('123'), expect.objectContaining({waitForTransition: true})); + }); + + it('does not park a link the app never replays', async () => { + // Given onboarding is on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + + // When the captured link is the test tools modal, which is app state in the URL rather than a destination + await handleLinkCapturedBeforeOnboarding(ROUTES.TEST_TOOLS_MODAL.route); + + // Then it is dropped and onboarding keeps its usual back-button message + expect(mockedSetDeepLinkToOpenAfterOnboarding).not.toHaveBeenCalled(); + expect(mockedSetOnboardingErrorMessage).toHaveBeenCalledWith('onboarding.purpose.errorBackButton'); + }); +}); From a8f16c267eb8a0c8ac433e950b822b334d34042b Mon Sep 17 00:00:00 2001 From: Mukher Date: Sun, 11 Oct 2026 17:26:10 +0300 Subject: [PATCH 4/8] Open Concierge for accounts that skip onboarding The Concierge page waited for the onboarding flag, but workspace members, invited users and other accounts the app never sends to onboarding keep that flag false, so the page stayed on its skeleton. Keep waiting only while onboarding can actually open. Match the parked Concierge link by its parsed route so concierge/ and concierge?x=1 also open the chat directly, and add the missing When/Then comments to the parked link tests. --- src/libs/actions/Link.ts | 2 +- src/pages/ConciergePage.tsx | 36 ++++++++++++++++++-- tests/ui/ConciergePageTest.tsx | 19 +++++++++++ tests/unit/LinkOpenReportFromDeepLinkTest.ts | 15 ++++++++ tests/unit/navigateAfterOnboardingTest.ts | 8 +++++ 5 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 4c97a1dce6da..f9f7f97a15d5 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -637,7 +637,7 @@ function openReportFromDeepLink( Onyx.disconnect(loadingConnection); // The concierge route is a URL entry page that redirects itself, and on wide layouts it slides in as a // panel first, so open the chat the way the rest of the app does instead. - if (route === ROUTES.CONCIERGE) { + if (findFocusedRoute(getStateFromPath(route as Route))?.name === SCREENS.CONCIERGE) { navigateToConciergeChat({conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, conciergePersonalDetails}); return; } diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 8b76e53c5282..bfd388f75175 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -9,17 +9,30 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {navigateToConciergeChat} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; +import navigationRef from '@libs/Navigation/navigationRef'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {OnyxValue} from 'react-native-onyx'; + import {useFocusEffect} from '@react-navigation/native'; -import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector} from '@selectors/Onboarding'; +import {isActingAsDelegateSelector} from '@selectors/Account'; +import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; +import {hasCompletedGuidedSetupFlowSelector, hasSeenTourSelector, tryNewDotOnyxSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding'; +import {isSupportalSessionSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useRef} from 'react'; import {View} from 'react-native'; +const isMigratedUserSelector = (tryNewDot: OnyxValue) => tryNewDotOnyxSelector(tryNewDot).hasBeenAddedToNudgeMigration; + +function isOnboardingOpen() { + return navigationRef.isReady() && !!navigationRef.getRootState()?.routes.some((route) => route.name === NAVIGATORS.ONBOARDING_MODAL_NAVIGATOR); +} + /* * This is a "utility page", that does this: * - If the user is authenticated, find their concierge chat and re-route to it @@ -36,6 +49,18 @@ function ConciergePage() { const [conciergePersonalDetails] = usePersonalDetailsByIDs([CONST.ACCOUNT_ID.CONCIERGE]); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [hasCompletedGuidedSetupFlow] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasCompletedGuidedSetupFlowSelector}); + const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY); + const [isMigratedUser] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {selector: isMigratedUserSelector}); + const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector}); + const [isSingleNewDotEntry] = useOnyx(ONYXKEYS.HYBRID_APP, {selector: isSingleNewDotEntrySelector}); + // The accounts useOnboardingFlow never sends to onboarding, so their flag can stay false for good. + const isOnboardingSkipped = + !!hasNonPersonalPolicy || + !!wasInvitedToNewDotSelector(introSelected) || + !!isMigratedUser || + isSupportalSessionSelector(session) || + !!isActingAsDelegate || + (CONFIG.IS_HYBRID_APP && !!isSingleNewDotEntry); // Onboarding opens on top of this page for a new account, and the redirect below dismisses the top modal, so it // would close onboarding before it is finished. The link is opened once onboarding completes instead. const isWaitingForOnboarding = !CONFIG.SKIP_ONBOARDING && hasCompletedGuidedSetupFlow === false; @@ -44,7 +69,12 @@ function ConciergePage() { useCallback(() => { if (session && 'authToken' in session) { Navigation.isNavigationReady().then(() => { - if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData || isWaitingForOnboarding) { + if (isUnmounted.current || isLoadingReportData === undefined || !!isLoadingReportData) { + return; + } + + // Onboarding can join or create a workspace before it finishes, so an open onboarding still wins. + if (isWaitingForOnboarding && (!isOnboardingSkipped || isOnboardingOpen())) { return; } @@ -61,7 +91,7 @@ function ConciergePage() { } else { Navigation.navigate(ROUTES.INBOX); } - }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, conciergePersonalDetails, isWaitingForOnboarding]), + }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, conciergePersonalDetails, isWaitingForOnboarding, isOnboardingSkipped]), ); useEffect(() => { diff --git a/tests/ui/ConciergePageTest.tsx b/tests/ui/ConciergePageTest.tsx index 2893b0fd3908..57ac582b4d2f 100644 --- a/tests/ui/ConciergePageTest.tsx +++ b/tests/ui/ConciergePageTest.tsx @@ -9,6 +9,7 @@ import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigati import ConciergePage from '@pages/ConciergePage'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import SCREENS from '@src/SCREENS'; @@ -112,4 +113,22 @@ describe('ConciergePage', () => { // Then the chat opens expect(mockNavigateToConciergeChat).toHaveBeenCalledTimes(1); }); + + it.each([ + ['a workspace member', ONYXKEYS.HAS_NON_PERSONAL_POLICY, true], + ['an invited user', ONYXKEYS.NVP_INTRO_SELECTED, {inviteType: CONST.ONBOARDING_INVITE_TYPES.WORKSPACE}], + ] as const)('opens the Concierge chat for %s who never finished onboarding', async (_, key, value) => { + // Given an account the app never sends to onboarding, so its onboarding flag stays false for good + await act(async () => { + await seedSignedInAccount(false); + await Onyx.set(key, value); + }); + + // When the page mounts + renderPage(); + await waitForBatchedUpdatesWithAct(); + + // Then it redirects instead of waiting for an onboarding that will never open + expect(mockNavigateToConciergeChat).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/unit/LinkOpenReportFromDeepLinkTest.ts b/tests/unit/LinkOpenReportFromDeepLinkTest.ts index 47f864ff68df..5e677857617a 100644 --- a/tests/unit/LinkOpenReportFromDeepLinkTest.ts +++ b/tests/unit/LinkOpenReportFromDeepLinkTest.ts @@ -141,6 +141,21 @@ describe('openReportFromDeepLink before onboarding', () => { expect(mockedNavigation.navigate).not.toHaveBeenCalled(); }); + it.each([`${ROUTES.CONCIERGE}/`, `${ROUTES.CONCIERGE}?referrer=email`])('opens a parked Concierge link written as %s in the chat as well', async (route) => { + // Given a Concierge link with a trailing slash or a query string, parked while onboarding was on screen + focusOn(SCREENS.ONBOARDING.PURPOSE); + await handleLinkCapturedBeforeOnboarding(route); + + // When onboarding finishes and the data has loaded + await Onyx.set(ONYXKEYS.IS_LOADING_REPORT_DATA, false); + replayParkedLink(); + await waitForBatchedUpdates(); + + // Then it still skips the Concierge page, which would slide in as a panel before redirecting + expect(mockedNavigateToConciergeChat).toHaveBeenCalledTimes(1); + expect(mockedNavigation.navigate).not.toHaveBeenCalled(); + }); + it('also parks the link when onboarding is no longer the focused screen', async () => { // Given the onboarding screens already closed by the time the link is handled focusOn(NAVIGATORS.TAB_NAVIGATOR); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index 8c6773c88af7..3cda0613f964 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -217,10 +217,12 @@ describe('navigateAfterOnboarding', () => { const openDeepLink = jest.fn(() => true); setDeepLinkToOpenAfterOnboarding(openDeepLink); + // When onboarding finishes navigateAfterOnboarding(true, true, REPORT_ID, {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID, false, { variantOverride: CONST.ONBOARDING_RHP_VARIANT.TRACK_EXPENSES_WITH_CONCIERGE, }); + // Then the parked link is still opened, since the variant branch would otherwise skip it expect(openDeepLink).toHaveBeenCalledTimes(1); expect(navigate).not.toHaveBeenCalled(); }); @@ -233,8 +235,10 @@ describe('navigateAfterOnboarding', () => { }); setDeepLinkToOpenAfterOnboarding(openDeepLink); + // When onboarding finishes navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + // Then the user still lands somewhere instead of on an empty screen expect(openDeepLink).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); @@ -245,8 +249,10 @@ describe('navigateAfterOnboarding', () => { const openDeepLink = jest.fn(() => false); setDeepLinkToOpenAfterOnboarding(openDeepLink); + // When onboarding finishes navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + // Then the default destination is used, because nothing else was opened expect(openDeepLink).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); @@ -257,9 +263,11 @@ describe('navigateAfterOnboarding', () => { const openDeepLink = jest.fn(() => true); setDeepLinkToOpenAfterOnboarding(openDeepLink); + // When onboarding exits twice navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); navigateAfterOnboarding(false, true, '', {}, undefined, ONBOARDING_ADMINS_CHAT_REPORT_ID); + // Then the link opens only the first time and the second exit uses the default destination expect(openDeepLink).toHaveBeenCalledTimes(1); expect(navigate).toHaveBeenCalledWith(ROUTES.REPORT_WITH_ID.getRoute(ONBOARDING_ADMINS_CHAT_REPORT_ID), undefined); }); From e872e5b748a2263397f381c8a977aa83126b18a0 Mon Sep 17 00:00:00 2001 From: Mukher Date: Thu, 15 Oct 2026 16:18:21 +0500 Subject: [PATCH 5/8] Reuse the deep link route cast for the Concierge check The new cast pushed Link.ts over its no-unsafe-type-assertion allowance, so hoist the existing one and share it. --- src/libs/actions/Link.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 915639c7f3d3..8010fd45b84d 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -569,9 +569,10 @@ function openReportFromDeepLink( const state = navigationRef.getRootState(); const currentFocusedRoute = findFocusedRoute(state); + const deeplinkRoute = route as Route; + const navigateHandler = (reportParam?: OnyxEntry) => { // Skip if the user already is in the deeplinked route. - const deeplinkRoute = route as Route; if (deeplinkRoute && Navigation.isActiveRoute(deeplinkRoute)) { return; } @@ -648,7 +649,7 @@ function openReportFromDeepLink( Onyx.disconnect(loadingConnection); // The concierge route is a URL entry page that redirects itself, and on wide layouts it slides in as a // panel first, so open the chat the way the rest of the app does instead. - if (findFocusedRoute(getStateFromPath(route as Route))?.name === SCREENS.CONCIERGE) { + if (findFocusedRoute(getStateFromPath(deeplinkRoute))?.name === SCREENS.CONCIERGE) { navigateToConciergeChat({ conciergeReportID, introSelected, From 9279c84a9de86a85394cf423eb4852ab835ca92a Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:21:49 +0500 Subject: [PATCH 6/8] Update src/libs/actions/Link.ts Co-authored-by: Rocio Perez-Cano --- src/libs/actions/Link.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index 8010fd45b84d..e83a24383d13 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -647,6 +647,7 @@ function openReportFromDeepLink( return; } Onyx.disconnect(loadingConnection); + // The concierge route is a URL entry page that redirects itself, and on wide layouts it slides in as a // panel first, so open the chat the way the rest of the app does instead. if (findFocusedRoute(getStateFromPath(deeplinkRoute))?.name === SCREENS.CONCIERGE) { From dff42a4d9a03113749a62ca23b146e0ef3a887d1 Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:22:11 +0500 Subject: [PATCH 7/8] Update src/pages/ConciergePage.tsx Co-authored-by: Rocio Perez-Cano --- src/pages/ConciergePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 73d61aba8071..7665b2ae7c21 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -54,6 +54,7 @@ function ConciergePage() { const [isMigratedUser] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, {selector: isMigratedUserSelector}); const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector}); const [isSingleNewDotEntry] = useOnyx(ONYXKEYS.HYBRID_APP, {selector: isSingleNewDotEntrySelector}); + // The accounts useOnboardingFlow never sends to onboarding, so their flag can stay false for good. const isOnboardingSkipped = !!hasNonPersonalPolicy || From b6e4c60ec0d6c5e26bb9c10b75a0b2a6c3dd953b Mon Sep 17 00:00:00 2001 From: Mukhriddin Shakhriyorov <71601329+mukhrr@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:22:20 +0500 Subject: [PATCH 8/8] Update src/pages/ConciergePage.tsx Co-authored-by: Rocio Perez-Cano --- src/pages/ConciergePage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index 7665b2ae7c21..3c0a26130717 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -63,6 +63,7 @@ function ConciergePage() { isSupportalSessionSelector(session) || !!isActingAsDelegate || (CONFIG.IS_HYBRID_APP && !!isSingleNewDotEntry); + // Onboarding opens on top of this page for a new account, and the redirect below dismisses the top modal, so it // would close onboarding before it is finished. The link is opened once onboarding completes instead. const isWaitingForOnboarding = !CONFIG.SKIP_ONBOARDING && hasCompletedGuidedSetupFlow === false;