From 38ddaa17578780c6217d390f5a891a4972ea3570 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Fri, 20 Feb 2026 11:20:55 +0100 Subject: [PATCH 1/5] Extract DeepLinkHandler from Expensify.tsx into its own component Move deep link handling logic (cold boot URL capture, HybridApp getInitialURL, and Linking event listener) out of Expensify.tsx into a dedicated DeepLinkHandler component to reduce complexity and improve separation of concerns. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/DeepLinkHandler.tsx | 79 +++++++++++++++++++++++++++++++++++++++++ src/Expensify.tsx | 55 +++------------------------- 2 files changed, 83 insertions(+), 51 deletions(-) create mode 100644 src/DeepLinkHandler.tsx diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx new file mode 100644 index 0000000000000..16bf0562efe47 --- /dev/null +++ b/src/DeepLinkHandler.tsx @@ -0,0 +1,79 @@ +import {useEffect, useRef} from 'react'; +import type {NativeEventSubscription} from 'react-native'; +import {Linking} from 'react-native'; +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 CONST from './CONST'; +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; +}; + +/** + * Renderless component that 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(null); + + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); + const [, sessionMetadata] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID, {canBeMissing: true}); + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); + 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; diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 2b5a5d892640b..b86fc5fc8d3a6 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -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'; @@ -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'; @@ -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'; @@ -57,7 +55,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') { @@ -98,7 +95,6 @@ type ExpensifyProps = { }; function Expensify() { const appStateChangeListener = useRef(null); - const linkingChangeListener = useRef(null); const hasLoggedDelegateMismatchRef = useRef(false); const hasHandledMissingIsLoadingAppRef = useRef(false); const [isNavigationReady, setIsNavigationReady] = useState(false); @@ -108,7 +104,7 @@ function Expensify() { const [hasAttemptedToOpenPublicRoom, setAttemptedToOpenPublicRoom] = useState(false); const {translate, preferredLocale} = useLocalize(); const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); - const [session, sessionMetadata] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); + const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE, {canBeMissing: true}); const [userMetadata] = useOnyx(ONYXKEYS.USER_METADATA, {canBeMissing: true}); const [isCheckingPublicRoom = true] = useOnyx(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, {initWithStoredValues: false, canBeMissing: true}); @@ -117,14 +113,11 @@ function Expensify() { const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {canBeMissing: true}); const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST, {canBeMissing: true}); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const {isOffline} = useNetwork(); const [stashedCredentials = CONST.EMPTY_OBJECT] = useOnyx(ONYXKEYS.STASHED_CREDENTIALS, {canBeMissing: true}); const [stashedSession] = useOnyx(ONYXKEYS.STASHED_SESSION, {canBeMissing: true}); - const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, {canBeMissing: true}); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID, {canBeMissing: true}); useDebugShortcut(); usePriorityMode(); @@ -330,47 +323,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; @@ -465,6 +417,7 @@ function Expensify() { )} + {hasAttemptedToOpenPublicRoom && ( Date: Mon, 23 Feb 2026 15:56:46 +0100 Subject: [PATCH 2/5] rm redundant onyx calls --- src/Expensify.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 1467508484047..c4be87d7dea05 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -95,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}); @@ -104,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(); From fe1b64d10aa887206fe81dc3eca8b050d4c1744e Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 23 Feb 2026 15:59:37 +0100 Subject: [PATCH 3/5] fix: spellcheck --- src/DeepLinkHandler.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 74852c6f7599c..1b48c8c59ad2a 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -19,7 +19,7 @@ type DeepLinkHandlerProps = { }; /** - * Renderless component that isolates the COLLECTION.REPORT Onyx subscription + * 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. */ From 75feae743df4c854a1ce250e6609110928409a95 Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 23 Feb 2026 16:05:17 +0100 Subject: [PATCH 4/5] fix: prettier --- src/DeepLinkHandler.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx index 1b48c8c59ad2a..e73386b23f086 100644 --- a/src/DeepLinkHandler.tsx +++ b/src/DeepLinkHandler.tsx @@ -1,6 +1,7 @@ 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'; @@ -8,7 +9,6 @@ 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 CONST from './CONST'; import ONYXKEYS from './ONYXKEYS'; import type {Route} from './ROUTES'; import isLoadingOnyxValue from './types/utils/isLoadingOnyxValue'; From 8589aa6966b42b586fe9ac239a61ce15b3ef1c6c Mon Sep 17 00:00:00 2001 From: Adam Horodyski Date: Mon, 23 Feb 2026 17:19:09 +0100 Subject: [PATCH 5/5] fix: prettier import ordering in ConnectToNetSuiteFlow and netsuite utils --- src/components/ConnectToNetSuiteFlow/index.tsx | 2 +- src/pages/workspace/accounting/netsuite/utils.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ConnectToNetSuiteFlow/index.tsx b/src/components/ConnectToNetSuiteFlow/index.tsx index 28a87da7e8ced..52824a331fa6c 100644 --- a/src/components/ConnectToNetSuiteFlow/index.tsx +++ b/src/components/ConnectToNetSuiteFlow/index.tsx @@ -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) { diff --git a/src/pages/workspace/accounting/netsuite/utils.ts b/src/pages/workspace/accounting/netsuite/utils.ts index 1e47998a41f70..ada9c3b5122f5 100644 --- a/src/pages/workspace/accounting/netsuite/utils.ts +++ b/src/pages/workspace/accounting/netsuite/utils.ts @@ -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;