Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c307c84
fix: navigate to Concierge chat after login from deep link on web
x-dev90 Jul 17, 2026
f870138
Fix missing pending Concierge deep link helper
x-dev90 Jul 17, 2026
9f44d64
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 17, 2026
b972082
Add comment for pending Concierge intent helper
x-dev90 Jul 17, 2026
0ceaa6f
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 18, 2026
e6df449
fix: persist pending Concierge deep link across reloads
x-dev90 Jul 18, 2026
91e65ec
fix: use type import for pending Concierge reload test
x-dev90 Jul 18, 2026
537a2e3
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 21, 2026
eea9b8d
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 21, 2026
3f4a0eb
Fix Concierge onboarding deep link persistence and cancellation
x-dev90 Jul 21, 2026
74735c9
Fix lint-safe Concierge deep link reload detection
x-dev90 Jul 21, 2026
c2cf6b2
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 22, 2026
71c10c7
Fix Concierge deep-link intent through onboarding edge cases
x-dev90 Jul 22, 2026
e181a8c
Fix Concierge deep link after onboarding refresh flows
x-dev90 Jul 25, 2026
b66c1b6
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 25, 2026
a5eb8b0
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 25, 2026
60d03b2
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 27, 2026
ee4b83b
Fix onboarding navigation tests after merge updates
x-dev90 Jul 27, 2026
9dfe564
Fix onboarding navigation test after merge updates
x-dev90 Jul 27, 2026
5198605
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 28, 2026
09f0907
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 29, 2026
10a4afe
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 30, 2026
39af2f4
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Jul 30, 2026
c1d2933
Fix Concierge deep link before onboarding modal unmounts
x-dev90 Jul 30, 2026
2e8af74
Fix two-tab Concierge onboarding intent cancellation
x-dev90 Jul 30, 2026
e2adfa8
Fix unsafe finally return in Track onboarding navigation
x-dev90 Jul 30, 2026
033c831
fix: prevent Concierge flash after root signup onboarding
x-dev90 Jul 30, 2026
6f7abdb
fix: reset Escape dismiss state before onboarding deep-link navigation
x-dev90 Jul 30, 2026
ba32e0f
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Aug 1, 2026
4701899
fix: keep Concierge deep link focused through onboarding
x-dev90 Aug 1, 2026
2302dca
fix: make Track onboarding cleanup lint-safe
x-dev90 Aug 1, 2026
7b7175b
Merge branch 'main' into fix/web-concierge-deep-link-login-navigation
x-dev90 Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/DeepLinkHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}

Expand Down
14 changes: 12 additions & 2 deletions src/hooks/useAutoCreateSubmitWorkspace.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -88,6 +88,7 @@ function useAutoCreateSubmitWorkspace() {
hasActiveAdminPolicies,
})
: {adminsChatReportID: onboardingAdminsChatReportID, policyID: onboardingPolicyID};
let didNavigateToPendingDeepLink = false;

if (shouldCompleteOnboarding) {
try {
Expand All @@ -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
Expand All @@ -122,7 +126,12 @@ function useAutoCreateSubmitWorkspace() {
policyIDForNavigation = existingSubmitPolicyID;
}

navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout);
if (didNavigateToPendingDeepLink) {
return;
}

// Pass conciergeReportID so true onboarding completion can honor a pending /concierge intent after refresh.
navigateToSubmitWorkspaceAfterOnboardingWithMicrotaskQueue(policyIDForNavigation, shouldUseNarrowLayout, conciergeReportID, shouldCompleteOnboarding);
},
[
currentUserEmail,
Expand All @@ -144,6 +153,7 @@ function useAutoCreateSubmitWorkspace() {
hasActiveAdminPolicies,
shouldUseNarrowLayout,
conciergeChat,
conciergeReportID,
],
);

Expand Down
28 changes: 17 additions & 11 deletions src/hooks/useAutoCreateTrackWorkspace.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -116,6 +117,9 @@ function useAutoCreateTrackWorkspace() {
isSelfTourViewed,
conciergeChat,
selfDMReport,
onBeforeOnboardingModalUnmount: () => {
didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeChatReportID);
},
});

if (isSidePanelReportSupported) {
Expand All @@ -133,16 +137,18 @@ function useAutoCreateTrackWorkspace() {
setOnboardingAdminsChatReportID();
setOnboardingPolicyID();

navigateAfterOnboardingWithMicrotaskQueue(
shouldUseNarrowLayout,
isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS),
conciergeChatReportID,
reportNameValuePairs,
newPolicyID,
mergedAccountConciergeReportID,
false,
{variantOverride: rhpVariant},
);
if (!didNavigateToPendingDeepLink) {
navigateAfterOnboardingWithMicrotaskQueue(
shouldUseNarrowLayout,
isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS),
conciergeChatReportID,
reportNameValuePairs,
newPolicyID,
mergedAccountConciergeReportID,
false,
{variantOverride: rhpVariant},
);
}
}
},
[
Expand Down
11 changes: 10 additions & 1 deletion src/hooks/useCompleteOnboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -118,6 +119,9 @@ function useCompleteOnboarding() {
isSelfTourViewed,
conciergeChat,
adminsChatReport,
onBeforeOnboardingModalUnmount: () => {
didNavigateToPendingDeepLink = navigateToPendingDeepLinkAfterOnboarding(conciergeReportID);
},
});
const rhpVariant = isSidePanelReportSupported ? extractRHPVariantFromResponse(response) : undefined;

Expand All @@ -129,6 +133,11 @@ function useCompleteOnboarding() {
waitForUpcomingTransition: true,
});

if (didNavigateToPendingDeepLink) {
setIsLoading(false);
return;
}

navigateAfterOnboardingWithMicrotaskQueue(
isSmallScreenWidth,
isBetaEnabled(CONST.BETAS.DEFAULT_ROOMS),
Expand Down
40 changes: 38 additions & 2 deletions src/libs/Navigation/linkingConfig/subscribe.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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';
Expand All @@ -11,6 +13,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.
*/
Expand All @@ -29,8 +33,40 @@ const skipRules: ReadonlyArray<{urlMatcher: RegExp; focusedScreens: readonly str
},
];

const subscribe: LinkingOptions<RootNavigatorParamList>['subscribe'] = (listener) => {
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<LinkingOptions<RootNavigatorParamList>['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) {
Expand All @@ -55,7 +91,7 @@ const subscribe: LinkingOptions<RootNavigatorParamList>['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);
Expand Down
144 changes: 144 additions & 0 deletions src/libs/PendingConciergeDeepLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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;
Comment thread
x-dev90 marked this conversation as resolved.
Comment thread
x-dev90 marked this conversation as resolved.

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<string, unknown> {
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 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();
}

// 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) {
if (normalizedRoute === '/' || 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 === '/' && isBrowserReload()) || normalizedRoute === normalizePath(ROUTES.HOME)) {
setPendingHomeDeepLinkIfNoPendingConcierge();
} else if (!isPublicScreenRoute(routeForPublicScreen)) {
clearPendingConciergeDeepLink();
}
}

function consumePendingConciergeDeepLink() {
const shouldNavigateToConcierge = hasPendingConciergeDeepLinkFlag();
clearPendingConciergeDeepLink();
return shouldNavigateToConcierge;
}

export {setPendingConciergeDeepLink, setPendingHomeDeepLinkIfNoPendingConcierge, updatePendingConciergeDeepLinkForRoute, consumePendingConciergeDeepLink, clearPendingConciergeDeepLink};
3 changes: 3 additions & 0 deletions src/libs/actions/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {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';
Expand Down Expand Up @@ -496,6 +497,8 @@ function openReportFromDeepLink(
route = '';
}

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)) {
return;
Expand Down
Loading
Loading