From ae3afed517d011f757a458f6d7bdf7d5ed7c4d19 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Tue, 25 Nov 2025 16:54:49 +0100 Subject: [PATCH 1/6] Restructuring of initialization API and configuration types --- benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx b/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx index 6d72cd601..fb352d37e 100644 --- a/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx +++ b/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx @@ -22,6 +22,7 @@ function SessionReplayScenario(props: SessionReplayScenarioProps): React.JSX.Ele textAndInputPrivacyLevel: TextAndInputPrivacyLevel.MASK_SENSITIVE_INPUTS, imagePrivacyLevel: ImagePrivacyLevel.MASK_NONE, touchPrivacyLevel: TouchPrivacyLevel.SHOW, + replaySampleRate: 100, }).then(() => { setIsReady(true); console.log("Session replay - start recording"); From 85fe92820a0d0bc0d080ec398bf39003e06d3636 Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Thu, 27 Nov 2025 19:24:09 +0100 Subject: [PATCH 2/6] Fix Typescript tests --- benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx b/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx index fb352d37e..6d72cd601 100644 --- a/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx +++ b/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx @@ -22,7 +22,6 @@ function SessionReplayScenario(props: SessionReplayScenarioProps): React.JSX.Ele textAndInputPrivacyLevel: TextAndInputPrivacyLevel.MASK_SENSITIVE_INPUTS, imagePrivacyLevel: ImagePrivacyLevel.MASK_NONE, touchPrivacyLevel: TouchPrivacyLevel.SHOW, - replaySampleRate: 100, }).then(() => { setIsReady(true); console.log("Session replay - start recording"); From 040fd7c40914868456afe71e8e8ad163a4bcac4d Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Dec 2025 16:58:46 +0100 Subject: [PATCH 3/6] Use global instance for DatadogProvider state * Fixes an issue that happens consistently on Expo, where the import duplicates the isInitialized state variable, preventing the SDK to be correctly attached to the native implementation --- .../codepush/src/__tests__/index.test.tsx | 12 ++++++++ .../sdk/DatadogProvider/DatadogProvider.tsx | 8 ++--- .../DatadogProvider/DatadogProviderState.ts | 30 +++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/sdk/DatadogProvider/DatadogProviderState.ts diff --git a/packages/codepush/src/__tests__/index.test.tsx b/packages/codepush/src/__tests__/index.test.tsx index d2d212b08..cb2f09ac3 100644 --- a/packages/codepush/src/__tests__/index.test.tsx +++ b/packages/codepush/src/__tests__/index.test.tsx @@ -37,6 +37,18 @@ const createCodepushPackageMock = label => ({ describe('AppCenter Codepush integration', () => { beforeEach(() => { jest.clearAllMocks(); + const _globalThis = (globalThis as unknown) as Record< + PropertyKey, + unknown + >; + const providerState = _globalThis[ + Symbol.for('com.datadog.reactnative.rum.datadog_provider_state') + ] as + | { + _reset: () => void; + } + | undefined; + providerState?._reset(); }); describe('initialize', () => { diff --git a/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx b/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx index d3dc7133c..c0b452f86 100644 --- a/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx +++ b/packages/core/src/sdk/DatadogProvider/DatadogProvider.tsx @@ -19,7 +19,7 @@ import { InternalLog } from '../../InternalLog'; import { SdkVerbosity } from '../../SdkVerbosity'; import type { FileBasedConfiguration } from '../FileBasedConfiguration/FileBasedConfiguration'; -let isInitialized = false; +import { DatadogProviderState } from './DatadogProviderState'; type Props = PropsWithChildren<{ /** @@ -87,7 +87,7 @@ export const DatadogProvider: React.FC & StaticProperties = ({ configuration, onInitialization }) => { - if (!isInitialized) { + if (!DatadogProviderState.isInitialized) { // Here we cannot use a useEffect hook since it would be called after // the first render. Thus, we wouldn't enable auto-instrumentation on // the elements rendered in this first render and what happens during @@ -98,7 +98,7 @@ export const DatadogProvider: React.FC & StaticProperties = ({ } else { initializeDatadog(configuration, onInitialization); } - isInitialized = true; + DatadogProviderState.setInitialized(); } return <>{children}; @@ -120,5 +120,5 @@ DatadogProvider.initialize = async ( }; export const __internalResetIsInitializedForTesting = () => { - isInitialized = false; + DatadogProviderState._reset(); }; diff --git a/packages/core/src/sdk/DatadogProvider/DatadogProviderState.ts b/packages/core/src/sdk/DatadogProvider/DatadogProviderState.ts new file mode 100644 index 000000000..168d36749 --- /dev/null +++ b/packages/core/src/sdk/DatadogProvider/DatadogProviderState.ts @@ -0,0 +1,30 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ +import { getGlobalInstance } from '../../utils/singletonUtils'; + +const DATADOG_PROVIDER_STATE_MODULE = + 'com.datadog.reactnative.rum.datadog_provider_state'; + +class _DatadogProviderState { + private _isInitialized: boolean = false; + + get isInitialized(): boolean { + return this._isInitialized; + } + + setInitialized() { + this._isInitialized = true; + } + + _reset() { + this._isInitialized = false; + } +} + +export const DatadogProviderState = getGlobalInstance( + DATADOG_PROVIDER_STATE_MODULE, + () => new _DatadogProviderState() +); From 0e7fd8f9dcad36de1bd8389b916f7a9a3acce467 Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Dec 2025 17:06:21 +0100 Subject: [PATCH 4/6] Use global instance for GlobalState --- .../src/__tests__/DdSdkReactNative.test.tsx | 4 ++-- packages/core/src/rum/DdRum.ts | 2 +- packages/core/src/rum/__tests__/DdRum.test.ts | 4 ++-- .../__tests__/initialization.test.tsx | 2 +- .../__tests__/initializationModes.test.tsx | 2 +- .../core/src/sdk/GlobalState/GlobalState.tsx | 21 +++++++++---------- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/core/src/__tests__/DdSdkReactNative.test.tsx b/packages/core/src/__tests__/DdSdkReactNative.test.tsx index 34a1573f6..72341c0d6 100644 --- a/packages/core/src/__tests__/DdSdkReactNative.test.tsx +++ b/packages/core/src/__tests__/DdSdkReactNative.test.tsx @@ -64,7 +64,7 @@ jest.mock('../rum/instrumentation/DdRumErrorTracking', () => { }); beforeEach(async () => { - GlobalState.instance.isInitialized = false; + GlobalState.isInitialized = false; DdSdkReactNative['wasAutoInstrumented'] = false; NativeModules.DdSdk.initialize.mockClear(); NativeModules.DdSdk.addAttributes.mockClear(); @@ -174,7 +174,7 @@ describe('DdSdkReactNative', () => { '_dd.sdk_version': sdkVersion }); - expect(GlobalState.instance.isInitialized).toBe(false); + expect(GlobalState.isInitialized).toBe(false); expect( DdRumUserInteractionTracking.startTracking ).toHaveBeenCalledTimes(0); diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index b921df580..e4204f55a 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -423,7 +423,7 @@ class DdRumWrapper implements DdRumType { }; async getCurrentSessionId(): Promise { - if (!GlobalState.instance.isInitialized) { + if (!GlobalState.isInitialized) { return undefined; } const sessionId = await this.nativeRum.getCurrentSessionId(); diff --git a/packages/core/src/rum/__tests__/DdRum.test.ts b/packages/core/src/rum/__tests__/DdRum.test.ts index 0f3dd80b7..9296ad5a2 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -1816,7 +1816,7 @@ describe('DdRum', () => { describe('DdRum.getCurrentSessionId', () => { it('calls the native API if SDK is initialized', async () => { - GlobalState.instance.isInitialized = true; + GlobalState.isInitialized = true; const sessionId = await DdRum.getCurrentSessionId(); expect(NativeModules.DdRum.getCurrentSessionId).toHaveBeenCalled(); expect(sessionId).toBe('test-session-id'); @@ -1825,7 +1825,7 @@ describe('DdRum', () => { describe('DdRum.getCurrentSessionId', () => { it('returns undefined if SDK is not initialized', async () => { - GlobalState.instance.isInitialized = false; + GlobalState.isInitialized = false; const sessionId = await DdRum.getCurrentSessionId(); expect( NativeModules.DdRum.getCurrentSessionId diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 660a98560..9f43410ef 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -42,7 +42,7 @@ const flushPromises = () => describe('DatadogProvider', () => { afterEach(() => { jest.clearAllMocks(); - GlobalState.instance.isInitialized = false; + GlobalState.isInitialized = false; __internalResetIsInitializedForTesting(); BufferSingleton.reset(); (nowMock as any).mockReturnValue('timestamp_not_specified'); diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx index 239f47691..e64779f91 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx @@ -42,7 +42,7 @@ const flushPromises = () => describe('DatadogProvider', () => { beforeEach(() => { jest.clearAllMocks(); - GlobalState.instance.isInitialized = false; + GlobalState.isInitialized = false; DdSdkReactNative['wasAutoInstrumented'] = false; __internalResetIsInitializedForTesting(); BufferSingleton.reset(); diff --git a/packages/core/src/sdk/GlobalState/GlobalState.tsx b/packages/core/src/sdk/GlobalState/GlobalState.tsx index 70f4751d6..ad070bb66 100644 --- a/packages/core/src/sdk/GlobalState/GlobalState.tsx +++ b/packages/core/src/sdk/GlobalState/GlobalState.tsx @@ -4,23 +4,22 @@ * Copyright 2016-Present Datadog, Inc. */ +import { getGlobalInstance } from '../../utils/singletonUtils'; + +const GLOBAL_STATE_MODULE = 'com.datadog.reactnative.sdk.global_state'; + /** * A singleton container for attributes that are shared internally across all * the SDK classes. */ -export class GlobalState { +class _GlobalState { /** * `true` if the SDK is initialized, `false` otherwise. */ public isInitialized = false; - - // Singleton implementation - private static _instance: GlobalState | undefined = undefined; - public static get instance(): GlobalState { - if (this._instance === undefined) { - this._instance = new GlobalState(); - } - - return this._instance; - } } + +export const GlobalState = getGlobalInstance( + GLOBAL_STATE_MODULE, + () => new _GlobalState() +); From b6fb78706232f3c7ec03ab8be1b5b07309e5b01f Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Dec 2025 17:07:22 +0100 Subject: [PATCH 5/6] Use global instance for DdBabelInteractionTracking * Solves an issue where the config object is duplicated for multiple imports leading to incorrect state management, and interaction tracking being disabled --- packages/core/src/DdSdkReactNative.tsx | 2 +- .../DdBabelInteractionTracking.ts | 52 +++++++------------ 2 files changed, 21 insertions(+), 33 deletions(-) diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index 303004c7d..d655601f7 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -509,7 +509,7 @@ export class DdSdkReactNative { useAccessibilityLabel: configuration.useAccessibilityLabel }; - DdBabelInteractionTracking.getInstance(DdRum); + DdBabelInteractionTracking.attachRumInstance(DdRum); } if (DdSdkReactNative.wasAutoInstrumented) { diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts index 0657378d2..9906f2511 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdBabelInteractionTracking.ts @@ -5,6 +5,7 @@ */ import DdSdk from '../../../specs/NativeDdSdk'; +import { getGlobalInstance } from '../../../utils/singletonUtils'; import { DefaultTimeProvider } from '../../../utils/time-provider/DefaultTimeProvider'; import type { TimeProvider } from '../../../utils/time-provider/TimeProvider'; import type { DdRum } from '../../DdRum'; @@ -12,10 +13,8 @@ import { BABEL_PLUGIN_TELEMETRY } from '../../constants'; import type { RumActionType } from '../../types'; import { ActionSource } from '../../types'; -const StateErrors = { - ALREADY_INITIALIZED: - 'Interaction Tracking singleton already initialized, please use `getInstance`.' -} as const; +const BABEL_INTERACTION_TRACKING_MODULE = + 'com.datadog.reactnative.rum.babel_interaction_tracking'; type BabelConfig = { trackInteractions: boolean; @@ -32,10 +31,8 @@ type TargetObject = { [key: string]: any; }; -export class DdBabelInteractionTracking { - private static instance: DdBabelInteractionTracking | null = null; - - static config: BabelConfig = { +class BabelInteractionTracking { + config: BabelConfig = { trackInteractions: false, useAccessibilityLabel: true }; @@ -48,34 +45,20 @@ export class DdBabelInteractionTracking { isInitialized: boolean = false; - private constructor(ddRum?: typeof DdRum) { - if (DdBabelInteractionTracking.instance) { - throw new Error(StateErrors.ALREADY_INITIALIZED); - } - - if (ddRum) { - this.ddRum = ddRum; - } - - DdBabelInteractionTracking.instance = this; + getInstance() { + return DdBabelInteractionTracking; } - static getInstance(ddRum?: typeof DdRum) { - if (!DdBabelInteractionTracking.instance) { - DdBabelInteractionTracking.instance = new DdBabelInteractionTracking( - ddRum - ); - } - - return DdBabelInteractionTracking.instance; + attachRumInstance(ddRum: typeof DdRum) { + this.ddRum = ddRum; + this.isInitialized = true; } - static getTelemetryConfig() { + getTelemetryConfig() { return { babel_plugin: { enabled: !!globalThis.__DD_RN_BABEL_PLUGIN_ENABLED__, - track_interactions: !!DdBabelInteractionTracking.config - .trackInteractions + track_interactions: !!this.config.trackInteractions } }; } @@ -91,7 +74,7 @@ export class DdBabelInteractionTracking { ...attrs } = targetObject; - const { useAccessibilityLabel } = DdBabelInteractionTracking.config; + const { useAccessibilityLabel } = this.config; const tryContent = () => { const content = getContent?.(); @@ -144,7 +127,7 @@ export class DdBabelInteractionTracking { if (!this.telemetrySent) { DdSdk?.sendTelemetryLog( BABEL_PLUGIN_TELEMETRY, - DdBabelInteractionTracking.getTelemetryConfig(), + this.getTelemetryConfig(), { onlyOnce: true } ); @@ -153,7 +136,7 @@ export class DdBabelInteractionTracking { const targetName = this.getTargetName(targetObject); - const { trackInteractions } = DdBabelInteractionTracking.config; + const { trackInteractions } = this.config; if (trackInteractions) { this.ddRum @@ -178,3 +161,8 @@ export class DdBabelInteractionTracking { }; } } + +export const DdBabelInteractionTracking = getGlobalInstance( + BABEL_INTERACTION_TRACKING_MODULE, + () => new BabelInteractionTracking() +); From 06b6ddcf761605a22f8630046571ac533dfcdd6a Mon Sep 17 00:00:00 2001 From: Marco Saia Date: Tue, 9 Dec 2025 17:09:07 +0100 Subject: [PATCH 6/6] Use global instance for DdRumReactNavigationTracking --- packages/core/src/DdSdkReactNative.tsx | 4 +- packages/core/src/utils/singletonUtils.ts | 5 + .../DdRumReactNavigationTracking.test.tsx | 6 +- .../DdRumReactNavigationTracking.tsx | 214 ++++++++++-------- 4 files changed, 127 insertions(+), 102 deletions(-) diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index d655601f7..dbc598ed6 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -84,7 +84,7 @@ export class DdSdkReactNative { initializationModeForTelemetry: InitializationModeForTelemetry; } ): Promise => { - if (GlobalState.instance.isInitialized) { + if (GlobalState.isInitialized) { InternalLog.log( "Can't initialize Datadog, SDK was already initialized", SdkVerbosity.WARN @@ -106,7 +106,7 @@ export class DdSdkReactNative { ); InternalLog.log('Datadog SDK was initialized', SdkVerbosity.INFO); - GlobalState.instance.isInitialized = true; + GlobalState.isInitialized = true; BufferSingleton.onInitialization(); }; diff --git a/packages/core/src/utils/singletonUtils.ts b/packages/core/src/utils/singletonUtils.ts index 9f00c2cd0..2685095d7 100644 --- a/packages/core/src/utils/singletonUtils.ts +++ b/packages/core/src/utils/singletonUtils.ts @@ -1,3 +1,8 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2016-Present Datadog, Inc. + */ export const getGlobalInstance = ( key: string, objectConstructor: () => T diff --git a/packages/react-navigation/src/__tests__/rum/instrumentation/DdRumReactNavigationTracking.test.tsx b/packages/react-navigation/src/__tests__/rum/instrumentation/DdRumReactNavigationTracking.test.tsx index 150257c42..a770b82f2 100644 --- a/packages/react-navigation/src/__tests__/rum/instrumentation/DdRumReactNavigationTracking.test.tsx +++ b/packages/react-navigation/src/__tests__/rum/instrumentation/DdRumReactNavigationTracking.test.tsx @@ -86,9 +86,7 @@ beforeEach(() => { mocked(BackHandler.exitApp).mockClear(); // @ts-ignore - DdRumReactNavigationTracking.registeredContainer = null; - // @ts-ignore - DdRumReactNavigationTracking.navigationStateChangeListener = null; + DdRumReactNavigationTracking._resetInternalStateForTesting(); }); // Unit tests @@ -357,7 +355,7 @@ describe.each([ expect(DdRum.startView).toHaveBeenCalledTimes(2); }); - it('does nothing when startTrackingViews { undefined any ', async () => { + it('does nothing when startTrackingViews { undefined any }', async () => { // WHEN DdRumReactNavigationTracking.startTrackingViews(null); diff --git a/packages/react-navigation/src/rum/instrumentation/DdRumReactNavigationTracking.tsx b/packages/react-navigation/src/rum/instrumentation/DdRumReactNavigationTracking.tsx index 913aa6e7b..24a31d6fd 100644 --- a/packages/react-navigation/src/rum/instrumentation/DdRumReactNavigationTracking.tsx +++ b/packages/react-navigation/src/rum/instrumentation/DdRumReactNavigationTracking.tsx @@ -16,6 +16,19 @@ import type { NavigationListener } from './react-navigation'; +const REACT_NAVIGATION_TRACKING_MODULE = + 'com.datadog.reactnative.rum.react_navigation_tracking'; + +function getGlobalInstance(key: string, objectConstructor: () => T): T { + const symbol = Symbol.for(key); + const g = (globalThis as unknown) as Record; + + if (!(symbol in g)) { + g[symbol] = objectConstructor(); + } + return g[symbol] as T; +} + // AppStateStatus can have values: // 'active' - The app is running in the foreground // 'background' - The app is running in the background. The user is either in another app or on the home screen @@ -61,9 +74,16 @@ function defaultViewTrackingPredicate(_route: Route) { /** * Provides RUM integration for the [ReactNavigation](https://reactnavigation.org/) API. */ -export class DdRumReactNavigationTracking { - private static _navigationTimeline?: NavigationTimeline; - private static get navigationTimeline(): NavigationTimeline | undefined { +class RumReactNavigationTracking { + readonly ROUTE_UNDEFINED_NAVIGATION_WARNING_MESSAGE = + 'A navigation change was detected but the RUM ViewEvent was dropped as the route was undefined.'; + readonly NULL_NAVIGATION_REF_ERROR_MESSAGE = + 'Cannot track views with a null navigationRef.'; + readonly NAVIGATION_REF_IN_USE_ERROR_MESSAGE = + 'Cannot track new navigation container while another one is still tracked. Please call `DdRumReactNavigationTracking.stopTrackingViews` on the previous container reference.'; + + private _navigationTimeline?: NavigationTimeline; + private get navigationTimeline(): NavigationTimeline | undefined { if (!this.__INTERNAL._enableNavigationTimeline) { return undefined; } @@ -73,40 +93,31 @@ export class DdRumReactNavigationTracking { return this._navigationTimeline; } - private static registeredContainer: NavigationContainerRef | null; - - private static navigationStateChangeListener: NavigationListener | null = null; + private registeredContainer: NavigationContainerRef | null = null; - private static previousRoute: string | object | undefined = undefined; + private navigationStateChangeListener: NavigationListener | null = null; - private static viewNamePredicate: ViewNamePredicate; + private previousRoute: string | object | undefined = undefined; - private static viewTrackingPredicate: ViewTrackingPredicate; + private viewNamePredicate: ViewNamePredicate = defaultViewNamePredicate; + private viewTrackingPredicate: ViewTrackingPredicate = defaultViewTrackingPredicate; + private paramsTrackingPredicate: ParamsTrackingPredicate = defaultParamsPredicate; - private static paramsTrackingPredicate: ParamsTrackingPredicate; + private backHandler: NativeEventSubscription | null = null; - private static backHandler: NativeEventSubscription | null; + private appStateSubscription?: NativeEventSubscription; - private static appStateSubscription?: NativeEventSubscription; + private previousAppState: AppStateStatus | undefined; - private static previousAppState: AppStateStatus | undefined; + private previousRouteKey: string | undefined; - private static previousRouteKey: string | undefined; - - private static trackingState: 'TRACKING' | 'NOT_TRACKING' = 'NOT_TRACKING'; - - static ROUTE_UNDEFINED_NAVIGATION_WARNING_MESSAGE = - 'A navigation change was detected but the RUM ViewEvent was dropped as the route was undefined.'; - static NULL_NAVIGATION_REF_ERROR_MESSAGE = - 'Cannot track views with a null navigationRef.'; - static NAVIGATION_REF_IN_USE_ERROR_MESSAGE = - 'Cannot track new navigation container while another one is still tracked. Please call `DdRumReactNavigationTracking.stopTrackingViews` on the previous container reference.'; + private trackingState: 'TRACKING' | 'NOT_TRACKING' = 'NOT_TRACKING'; /** * @internal * DO NOT USE: This API is for internal testing only. */ - static __INTERNAL = { + __INTERNAL = { /** * @internal * DO NOT USE: This API is for internal testing only. @@ -121,21 +132,19 @@ export class DdRumReactNavigationTracking { } }; - static isAppExitingOnBackPress = (): boolean => { - if (DdRumReactNavigationTracking.registeredContainer === null) { + isAppExitingOnBackPress = (): boolean => { + if (this.registeredContainer === null) { return false; } - if (DdRumReactNavigationTracking.registeredContainer.canGoBack()) { + if (this.registeredContainer.canGoBack()) { return false; } return true; }; - static onBackPress = () => { - if (DdRumReactNavigationTracking.isAppExitingOnBackPress()) { - DdRumReactNavigationTracking.stopTrackingViews( - DdRumReactNavigationTracking.registeredContainer - ); + onBackPress = () => { + if (this.isAppExitingOnBackPress()) { + this.stopTrackingViews(this.registeredContainer); } // We always return false so we make sure the react-navigation callback is called. // See https://reactnative.dev/docs/backhandler @@ -150,7 +159,7 @@ export class DdRumReactNavigationTracking { * viewTrackingPredicate: the predicate to determine if a view should be tracked or not. * paramsTrackingPredicate: the predicate to determine which parameters should be tracked for a given view. */ - static startTrackingViews( + startTrackingViews( navigationRef: NavigationContainerRef | null, trackingOptions?: NavigationTrackingOptions ): void { @@ -164,36 +173,44 @@ export class DdRumReactNavigationTracking { if (navigationRef == null) { InternalLog.log( - DdRumReactNavigationTracking.NULL_NAVIGATION_REF_ERROR_MESSAGE, + this.NULL_NAVIGATION_REF_ERROR_MESSAGE, SdkVerbosity.ERROR ); return; } if ( - DdRumReactNavigationTracking.registeredContainer != null && + this.registeredContainer != null && this.registeredContainer !== navigationRef ) { InternalLog.log( - DdRumReactNavigationTracking.NAVIGATION_REF_IN_USE_ERROR_MESSAGE, + this.NAVIGATION_REF_IN_USE_ERROR_MESSAGE, SdkVerbosity.ERROR ); - } else if (DdRumReactNavigationTracking.registeredContainer == null) { - DdRumReactNavigationTracking.viewNamePredicate = viewNamePredicate; - DdRumReactNavigationTracking.viewTrackingPredicate = viewTrackingPredicate; - DdRumReactNavigationTracking.paramsTrackingPredicate = paramsTrackingPredicate; - DdRumReactNavigationTracking.registeredContainer = navigationRef; + } else if (this.registeredContainer == null) { + if (viewNamePredicate) { + this.viewNamePredicate = viewNamePredicate; + } + + if (viewTrackingPredicate) { + this.viewTrackingPredicate = viewTrackingPredicate; + } - const listener = DdRumReactNavigationTracking.resolveNavigationStateChangeListener(); + if (paramsTrackingPredicate) { + this.paramsTrackingPredicate = paramsTrackingPredicate; + } + this.registeredContainer = navigationRef; + + const listener = this.resolveNavigationStateChangeListener(); navigationRef.addListener('state', listener); - DdRumReactNavigationTracking.backHandler = BackHandler.addEventListener( + this.backHandler = BackHandler.addEventListener( 'hardwareBackPress', - DdRumReactNavigationTracking.onBackPress + this.onBackPress ); this.appStateSubscription = AppState.addEventListener( 'change', - DdRumReactNavigationTracking.appStateListener + this.appStateListener ); } } @@ -202,25 +219,22 @@ export class DdRumReactNavigationTracking { * Stops tracking the NavigationContainer. * @param navigationRef the reference to the real NavigationContainer. */ - static stopTrackingViews( - navigationRef: NavigationContainerRef | null - ): void { + stopTrackingViews(navigationRef: NavigationContainerRef | null): void { this.navigationTimeline?.addStopTrackingEvent(); this.previousRoute = undefined; if (navigationRef != null) { - if (DdRumReactNavigationTracking.navigationStateChangeListener) { + if (this.navigationStateChangeListener) { navigationRef.removeListener( 'state', - DdRumReactNavigationTracking.navigationStateChangeListener + this.navigationStateChangeListener ); } - DdRumReactNavigationTracking.backHandler?.remove(); - DdRumReactNavigationTracking.backHandler = null; - DdRumReactNavigationTracking.registeredContainer = null; - DdRumReactNavigationTracking.navigationStateChangeListener = null; - DdRumReactNavigationTracking.viewNamePredicate = defaultViewNamePredicate; - DdRumReactNavigationTracking.viewTrackingPredicate = defaultViewTrackingPredicate; - DdRumReactNavigationTracking.paramsTrackingPredicate = defaultParamsPredicate; + this.backHandler?.remove(); + this.backHandler = null; + this.registeredContainer = null; + this.navigationStateChangeListener = null; + + this.resetPredicates(); } // For versions of React Native below 0.65, addEventListener does not return a subscription. @@ -235,37 +249,50 @@ export class DdRumReactNavigationTracking { } else if (AppState.removeEventListener) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - AppState.removeEventListener( - 'change', - DdRumReactNavigationTracking.appStateListener - ); + AppState.removeEventListener('change', this.appStateListener); } } - private static handleRouteNavigation( + _resetInternalStateForTesting(): void { + this._navigationTimeline = undefined; + this.registeredContainer = null; + this.navigationStateChangeListener = null; + this.previousRoute = undefined; + this.backHandler = null; + this.appStateSubscription = undefined; + this.previousAppState = undefined; + this.previousRouteKey = undefined; + this.trackingState = 'NOT_TRACKING'; + this.resetPredicates(); + } + + private resetPredicates() { + this.paramsTrackingPredicate = defaultParamsPredicate; + this.viewNamePredicate = defaultViewNamePredicate; + this.viewTrackingPredicate = defaultViewTrackingPredicate; + } + + private handleRouteNavigation( route: Route | undefined, appStateStatus: AppStateStatus, stateEvent: StateEvent | undefined ) { if (route === undefined || route === null) { InternalLog.log( - DdRumReactNavigationTracking.ROUTE_UNDEFINED_NAVIGATION_WARNING_MESSAGE, + this.ROUTE_UNDEFINED_NAVIGATION_WARNING_MESSAGE, SdkVerbosity.WARN ); // RUMM-1400 in some cases the route seem to be undefined return; } const key = route.key; - const screenName = DdRumReactNavigationTracking.viewNamePredicate( - route, - route.name - ); + const screenName = this.viewNamePredicate(route, route.name); if (key != null && screenName != null) { // On iOS, the app can start in either "active", "background" or "unknown" state if (appStateStatus !== 'background') { this.previousRoute = route; - DdRumReactNavigationTracking.trackingState = 'TRACKING'; + this.trackingState = 'TRACKING'; this.navigationTimeline?.addNavigationStateEvent( 'START_VIEW', key, @@ -275,10 +302,8 @@ export class DdRumReactNavigationTracking { trackingState: this.trackingState } ); - if (DdRumReactNavigationTracking.viewTrackingPredicate(route)) { - const params = DdRumReactNavigationTracking.paramsTrackingPredicate( - route - ); + if (this.viewTrackingPredicate(route)) { + const params = this.paramsTrackingPredicate(route); if (params) { DdRum.startView(key, screenName, { params }); } else { @@ -291,7 +316,7 @@ export class DdRumReactNavigationTracking { this.previousRouteKey = route.key; } - private static handleAppStateChanged( + private handleAppStateChanged( route: Route, appStateStatus: AppStateStatus ) { @@ -300,14 +325,11 @@ export class DdRumReactNavigationTracking { appStateStatus ); const key = route.key; - const screenName = DdRumReactNavigationTracking.viewNamePredicate( - route, - route.name - ); + const screenName = this.viewNamePredicate(route, route.name); if (key != null && screenName != null) { if (appStateStatus === 'background') { - DdRumReactNavigationTracking.trackingState = 'NOT_TRACKING'; + this.trackingState = 'NOT_TRACKING'; this.navigationTimeline?.addNavigationStateEvent( 'STOP_VIEW', key, @@ -321,11 +343,11 @@ export class DdRumReactNavigationTracking { this.previousRoute = undefined; } else if ( appStateStatus === 'active' && - DdRumReactNavigationTracking.trackingState === 'NOT_TRACKING' + this.trackingState === 'NOT_TRACKING' ) { // case when app goes into foreground, // in that case navigation listener won't be called - DdRumReactNavigationTracking.handleRouteNavigation( + this.handleRouteNavigation( route, AppState.currentState, appStateChangeEvent @@ -337,12 +359,10 @@ export class DdRumReactNavigationTracking { this.previousAppState = appStateStatus; } - private static resolveNavigationStateChangeListener(): NavigationListener { - if ( - DdRumReactNavigationTracking.navigationStateChangeListener == null - ) { - DdRumReactNavigationTracking.navigationStateChangeListener = () => { - const route = DdRumReactNavigationTracking.registeredContainer?.getCurrentRoute(); + private resolveNavigationStateChangeListener(): NavigationListener { + if (this.navigationStateChangeListener == null) { + this.navigationStateChangeListener = () => { + const route = this.registeredContainer?.getCurrentRoute(); const newRouteStateEvent = this.navigationTimeline?.addNewRouteEvent( this.previousRouteKey, @@ -351,7 +371,7 @@ export class DdRumReactNavigationTracking { if (route === undefined) { InternalLog.log( - DdRumReactNavigationTracking.ROUTE_UNDEFINED_NAVIGATION_WARNING_MESSAGE, + this.ROUTE_UNDEFINED_NAVIGATION_WARNING_MESSAGE, SdkVerbosity.WARN ); return; @@ -371,22 +391,22 @@ export class DdRumReactNavigationTracking { return; } - DdRumReactNavigationTracking.handleRouteNavigation( + this.handleRouteNavigation( route, AppState.currentState, newRouteStateEvent ); }; - DdRumReactNavigationTracking.navigationStateChangeListener({}); + this.navigationStateChangeListener({}); } - return DdRumReactNavigationTracking.navigationStateChangeListener; + return this.navigationStateChangeListener; } - private static appStateListener: AppStateListener = ( + private appStateListener: AppStateListener = ( appStateStatus: AppStateStatus ) => { - const currentRoute = DdRumReactNavigationTracking.registeredContainer?.getCurrentRoute(); + const currentRoute = this.registeredContainer?.getCurrentRoute(); if (currentRoute === undefined || currentRoute === null) { InternalLog.log( `We could not determine the route when changing the application state to: ${appStateStatus}. No RUM View event will be sent in this case.`, @@ -395,9 +415,11 @@ export class DdRumReactNavigationTracking { return; } - DdRumReactNavigationTracking.handleAppStateChanged( - currentRoute, - appStateStatus - ); + this.handleAppStateChanged(currentRoute, appStateStatus); }; } + +export const DdRumReactNavigationTracking = getGlobalInstance( + REACT_NAVIGATION_TRACKING_MODULE, + () => new RumReactNavigationTracking() +);