Skip to content
79 changes: 79 additions & 0 deletions src/DeepLinkHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {useEffect, useRef} from 'react';
import type {NativeEventSubscription} from 'react-native';
import {Linking} from 'react-native';
import CONST from './CONST';
import useIsAuthenticated from './hooks/useIsAuthenticated';
import useOnyx from './hooks/useOnyx';
import {openReportFromDeepLink} from './libs/actions/Link';
import * as Report from './libs/actions/Report';
import {hasAuthToken} from './libs/actions/Session';
import Log from './libs/Log';
import {endSpan} from './libs/telemetry/activeSpans';
import ONYXKEYS from './ONYXKEYS';
import type {Route} from './ROUTES';
import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue';

type DeepLinkHandlerProps = {
/** Callback to set the initial URL resolved from deep linking */
onInitialUrl: (url: Route | null) => void;
};

/**
* Component that does not render anything but isolates the COLLECTION.REPORT Onyx subscription
* from the root Expensify component to prevent cascading re-renders of the
* entire navigation tree on every report change.
*/
function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
const linkingChangeListener = useRef<NativeEventSubscription | null>(null);

const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [, sessionMetadata] = useOnyx(ONYXKEYS.SESSION);
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const isAuthenticated = useIsAuthenticated();

useEffect(() => {
if (isLoadingOnyxValue(sessionMetadata)) {
return;
}
// If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report
Linking.getInitialURL().then((url) => {
onInitialUrl(url as Route);

if (url) {
if (conciergeReportID === undefined) {
Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url});
}
if (introSelected === undefined) {
Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url});
}
openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected);
} else {
Report.doneCheckingPublicRoom();
}

endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK);
});

// Open chat report from a deep link (only mobile native)
linkingChangeListener.current = Linking.addEventListener('url', (state) => {
if (conciergeReportID === undefined) {
Log.info('[Deep link] conciergeReportID is undefined when processing URL change', false, {url: state.url});
}
if (introSelected === undefined) {
Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url});
}
const isCurrentlyAuthenticated = hasAuthToken();
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected);
});

return () => {
linkingChangeListener.current?.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this effect to re-run when conciergeReportID changes
}, [sessionMetadata?.status, conciergeReportID, introSelected]);

return null;
}

export default DeepLinkHandler;
55 changes: 4 additions & 51 deletions src/Expensify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import HybridAppModule from '@expensify/react-native-hybrid-app';
import * as Sentry from '@sentry/react-native';
import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
import type {NativeEventSubscription} from 'react-native';
import {AppState, Linking, Platform} from 'react-native';
import {AppState, Platform} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import Onyx from 'react-native-onyx';
import ConfirmModal from './components/ConfirmModal';
Expand All @@ -16,6 +16,7 @@ import SplashScreenHider from './components/SplashScreenHider';
import UpdateAppModal from './components/UpdateAppModal';
import CONFIG from './CONFIG';
import CONST from './CONST';
import DeepLinkHandler from './DeepLinkHandler';
import useDebugShortcut from './hooks/useDebugShortcut';
import useIsAuthenticated from './hooks/useIsAuthenticated';
import useLocalize from './hooks/useLocalize';
Expand All @@ -25,11 +26,8 @@ import usePriorityMode from './hooks/usePriorityChange';
import {confirmReadyToOpenApp, openApp, updateLastRoute} from './libs/actions/App';
import {disconnect} from './libs/actions/Delegate';
import * as EmojiPickerAction from './libs/actions/EmojiPickerAction';
import {openReportFromDeepLink} from './libs/actions/Link';
// This lib needs to be imported, but it has nothing to export since all it contains is an Onyx connection
import './libs/actions/replaceOptimisticReportWithActualReport';
import * as Report from './libs/actions/Report';
import {hasAuthToken} from './libs/actions/Session';
import * as User from './libs/actions/User';
import * as ActiveClientManager from './libs/ActiveClientManager';
import {isSafari} from './libs/Browser';
Expand All @@ -55,7 +53,6 @@ import * as ReportActionContextMenu from './pages/inbox/report/ContextMenu/Repor
import type {Route} from './ROUTES';
import {useSplashScreenActions, useSplashScreenState} from './SplashScreenStateContext';
import type {ScreenShareRequest} from './types/onyx';
import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue';

Onyx.registerLogger(({level, message, parameters}) => {
if (level === 'alert') {
Expand Down Expand Up @@ -89,7 +86,6 @@ type ExpensifyProps = {
};
function Expensify() {
const appStateChangeListener = useRef<NativeEventSubscription | null>(null);
const linkingChangeListener = useRef<NativeEventSubscription | null>(null);
const hasLoggedDelegateMismatchRef = useRef(false);
const hasHandledMissingIsLoadingAppRef = useRef(false);
const [isNavigationReady, setIsNavigationReady] = useState(false);
Expand All @@ -99,7 +95,7 @@ function Expensify() {
const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false);
const {translate, preferredLocale} = useLocalize();
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [session, sessionMetadata] = useOnyx(ONYXKEYS.SESSION);
const [session] = useOnyx(ONYXKEYS.SESSION);
const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE);
const [userMetadata] = useOnyx(ONYXKEYS.USER_METADATA);
const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, {initWithStoredValues: false});
Expand All @@ -108,14 +104,11 @@ function Expensify() {
const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED);
const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST);
const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH);
const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP);
const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
const {isOffline} = useNetwork();
const [stashedCredentials = CONST.EMPTY_OBJECT] = useOnyx(ONYXKEYS.STASHED_CREDENTIALS);
const [stashedSession] = useOnyx(ONYXKEYS.STASHED_SESSION);
const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED);
const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID);

useDebugShortcut();
usePriorityMode();
Expand Down Expand Up @@ -321,47 +314,6 @@ function Expensify() {
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
}, []);

useEffect(() => {
if (isLoadingOnyxValue(sessionMetadata)) {
return;
}
// If the app is opened from a deep link, get the reportID (if exists) from the deep link and navigate to the chat report
Linking.getInitialURL().then((url) => {
setInitialUrl(url as Route);

if (url) {
if (conciergeReportID === undefined) {
Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url});
}
if (introSelected === undefined) {
Log.info('[Deep link] introSelected is undefined when processing initial URL', false, {url});
}
openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID, introSelected);
} else {
Report.doneCheckingPublicRoom();
}

endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK);
});

// Open chat report from a deep link (only mobile native)
linkingChangeListener.current = Linking.addEventListener('url', (state) => {
if (conciergeReportID === undefined) {
Log.info('[Deep link] conciergeReportID is undefined when processing URL change', false, {url: state.url});
}
if (introSelected === undefined) {
Log.info('[Deep link] introSelected is undefined when processing URL change', false, {url: state.url});
}
const isCurrentlyAuthenticated = hasAuthToken();
openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID, introSelected);
});

return () => {
linkingChangeListener.current?.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- we only want this effect to re-run when conciergeReportID changes
}, [sessionMetadata?.status, conciergeReportID, introSelected]);

useLayoutEffect(() => {
if (!isNavigationReady || !lastRoute) {
return;
Expand Down Expand Up @@ -449,6 +401,7 @@ function Expensify() {
</>
)}

<DeepLinkHandler onInitialUrl={setInitialUrl} />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mount DeepLinkHandler before migration gate

Placing <DeepLinkHandler /> in the returned JSX means it is not mounted until after if (!isOnyxMigrated) return null;, but the previous deep-link effect in Expensify ran immediately on first mount even while migrations were still running. This shifts Linking.getInitialURL() and endSpan(CONST.TELEMETRY.SPAN_BOOTSPLASH.DEEP_LINK) later by the migration duration, so the deep-link bootsplash span now measures unrelated migration time and can skew startup telemetry/experiments, while also delaying doneCheckingPublicRoom() and unblocking navigation.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll can into this one. @mountiny do you think we should do a QA instead to make sure we can await the migrations safely?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think its better to add some QA even if it should be safe, better safe than sorry

Copy link
Contributor Author

@adhorodyski adhorodyski Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deeplinking still needs access to Onyx data, and this is should blocked until we apply any pending migrations.

the deep-link bootsplash span now measures unrelated migration time

I find this invalid, migration was and remains async, the span start in the same place. It's true it might take just a big longer to finish this span now.

<AppleAuthWrapper />
{hasAttemptedToOpenPublicRoom && (
<NavigationRoot
Expand Down
2 changes: 1 addition & 1 deletion src/components/ConnectToNetSuiteFlow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import {isAuthenticationError} from '@libs/actions/connections';
import {getAdminPoliciesConnectedToNetSuite} from '@libs/actions/Policy/Policy';
import Navigation from '@libs/Navigation/Navigation';
import {useAccountingContext} from '@pages/workspace/accounting/AccountingContext';
import {getInitialSubPageForNetsuiteTokenInput} from '@pages/workspace/accounting/netsuite/utils';
import type {AnchorPosition} from '@styles/index';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import {getInitialSubPageForNetsuiteTokenInput} from '@pages/workspace/accounting/netsuite/utils';
import type {ConnectToNetSuiteFlowProps} from './types';

function ConnectToNetSuiteFlow({policyID}: ConnectToNetSuiteFlowProps) {
Expand Down
4 changes: 2 additions & 2 deletions src/pages/workspace/accounting/netsuite/utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import {isAuthenticationError} from '@libs/actions/connections';
import {canUseProvincialTaxNetSuite, canUseTaxNetSuite} from '@libs/PolicyUtils';
import CONST from '@src/CONST';
import type {NetSuiteConnectionConfig, NetSuiteSubsidiary} from '@src/types/onyx/Policy';
import {isAuthenticationError} from '@libs/actions/connections';
import type Policy from '@src/types/onyx/Policy';
import type {OnyxEntry} from 'react-native-onyx';

function shouldHideReimbursedReportsSection(config?: NetSuiteConnectionConfig) {
return config?.reimbursableExpensesExportDestination === CONST.NETSUITE_EXPORT_DESTINATION.JOURNAL_ENTRY;
Expand Down
Loading