diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index bff416e95e49..e83a24383d13 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 {hasJoinWorkspaceTaskParam} from '@libs/Navigation/helpers/isJoinWorkspaceTaskPath'; import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; @@ -568,35 +569,10 @@ function openReportFromDeepLink( const state = navigationRef.getRootState(); const currentFocusedRoute = findFocusedRoute(state); - if (isOnboardingFlowName(currentFocusedRoute?.name)) { - if (hasCompletedGuidedSetupFlowSelector(val) === false) { - 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 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; } @@ -628,22 +604,96 @@ 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); + + // 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) { + navigateToConciergeChat({ + conciergeReportID, + introSelected, + currentUserAccountID, + isSelfTourViewed, + hasCompletedGuidedSetupFlow: hasCompletedGuidedSetupFlowSelector(val), + conciergePersonalDetails, + }); + return; + } + openDeepLink(); + }, + }); + return true; + }); + return true; + }; + + if (isOnboardingFlowName(currentFocusedRoute?.name)) { + if (deferUntilAfterOnboarding()) { + return; + } + + if (hasCompletedGuidedSetupFlowSelector(val) === false) { + 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 7e64fb96331e..4ef611026a60 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 @@ -163,6 +198,10 @@ function navigateAfterOnboardingWithMicrotaskQueue( function navigateToSubmitWorkspaceAfterOnboarding(policyID?: string, shouldUseNarrowLayout = false) { setDisableDismissOnEscape(false); + if (consumeDeepLinkToOpenAfterOnboarding()) { + return; + } + if (!policyID) { Navigation.navigate(ROUTES.HOME); return; @@ -181,4 +220,4 @@ function navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyID?: s }); } -export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; +export {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, setDeepLinkToOpenAfterOnboarding, navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue}; diff --git a/src/pages/ConciergePage.tsx b/src/pages/ConciergePage.tsx index f6745787676a..3c0a26130717 100644 --- a/src/pages/ConciergePage.tsx +++ b/src/pages/ConciergePage.tsx @@ -9,16 +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 {guidedSetupAndTourStatusSelector} from '@selectors/Onboarding'; +import {isActingAsDelegateSelector} from '@selectors/Account'; +import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; +import {guidedSetupAndTourStatusSelector, 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 +50,23 @@ function ConciergePage() { const [guidedSetupAndTourStatus] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: guidedSetupAndTourStatusSelector}); const isSelfTourViewed = guidedSetupAndTourStatus?.isSelfTourViewed; const hasCompletedGuidedSetupFlow = guidedSetupAndTourStatus?.hasCompletedGuidedSetupFlow; + 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; useFocusEffect( useCallback(() => { @@ -45,6 +76,11 @@ function ConciergePage() { return; } + // Onboarding can join or create a workspace before it finishes, so an open onboarding still wins. + if (isWaitingForOnboarding && (!isOnboardingSkipped || isOnboardingOpen())) { + return; + } + navigateToConciergeChat({ conciergeReportID, introSelected, @@ -59,7 +95,17 @@ function ConciergePage() { } else { Navigation.navigate(ROUTES.INBOX); } - }, [session, isLoadingReportData, conciergeReportID, introSelected, isSelfTourViewed, hasCompletedGuidedSetupFlow, conciergePersonalDetails]), + }, [ + session, + isLoadingReportData, + conciergeReportID, + introSelected, + isSelfTourViewed, + hasCompletedGuidedSetupFlow, + conciergePersonalDetails, + isWaitingForOnboarding, + isOnboardingSkipped, + ]), ); useEffect(() => { diff --git a/tests/ui/ConciergePageTest.tsx b/tests/ui/ConciergePageTest.tsx new file mode 100644 index 000000000000..57ac582b4d2f --- /dev/null +++ b/tests/ui/ConciergePageTest.tsx @@ -0,0 +1,134 @@ +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 CONST from '@src/CONST'; +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); + }); + + 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 new file mode 100644 index 000000000000..86a5ff354bde --- /dev/null +++ b/tests/unit/LinkOpenReportFromDeepLinkTest.ts @@ -0,0 +1,203 @@ +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, 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.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); + + // 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, without the back-button error, since onboarding is already complete when the link is handled + expect(mockedSetDeepLinkToOpenAfterOnboarding).not.toHaveBeenCalled(); + expect(mockedSetOnboardingErrorMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/navigateAfterOnboardingTest.ts b/tests/unit/navigateAfterOnboardingTest.ts index dc53e8312e37..eb572a164e3f 100644 --- a/tests/unit/navigateAfterOnboardingTest.ts +++ b/tests/unit/navigateAfterOnboardingTest.ts @@ -1,7 +1,8 @@ -import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue} from '@libs/navigateAfterOnboarding'; +import {navigateAfterOnboarding, navigateAfterOnboardingWithMicrotaskQueue, setDeepLinkToOpenAfterOnboarding} from '@libs/navigateAfterOnboarding'; import dismissOnboardingModalBeforeExit from '@libs/Navigation/helpers/OnboardingNavigationUtils'; 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'; @@ -97,6 +98,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'}; @@ -215,6 +221,81 @@ 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); + + // 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(); + }); + + 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); + + // 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); + }); + + 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); + + // 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); + }); + + 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); + + // 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); + }); + 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');