From 0e2bc9f99cb78c13a0118f77006af8df131d109d Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 17 Apr 2026 14:58:55 +0200 Subject: [PATCH 1/2] Fix ASYNC initialization on RN 0.81+ --- packages/core/src/DdSdkReactNative.tsx | 20 ++- .../config/DatadogProviderConfiguration.ts | 4 +- .../__utils__/renderWithProvider.tsx | 31 +++- .../__tests__/initialization.test.tsx | 34 +++-- .../__tests__/initializationModes.test.tsx | 139 +++++++++++------- 5 files changed, 149 insertions(+), 79 deletions(-) diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index bb0669a24..f6f3c6230 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -123,10 +123,26 @@ export class DdSdkReactNative { }); } if (configuration.initializationMode === InitializationMode.ASYNC) { - return InteractionManager.runAfterInteractions(() => { - return DdSdkReactNative.initializeNativeSDK(configuration, { + const initNative = () => + DdSdkReactNative.initializeNativeSDK(configuration, { initializationModeForTelemetry: 'ASYNC' }); + + // We rely on requestIdleCallback for RN >= 0.76 to make sure that the SDK initialization + // happens after rendering has finished + if ((globalThis as Record).requestIdleCallback) { + return new Promise(resolve => { + ((globalThis as unknown) as { + requestIdleCallback: (cb: () => void) => void; + }).requestIdleCallback(() => { + initNative().then(resolve); + }); + }); + } + + // On RN below 0.76 we use runAfterInteractions, which initializes the SDK after animations are done + return InteractionManager.runAfterInteractions(() => { + return initNative(); }); } // TODO: Remove when DdSdkReactNativeConfiguration is deprecated diff --git a/packages/core/src/config/DatadogProviderConfiguration.ts b/packages/core/src/config/DatadogProviderConfiguration.ts index b8bd020cf..11d88adcd 100644 --- a/packages/core/src/config/DatadogProviderConfiguration.ts +++ b/packages/core/src/config/DatadogProviderConfiguration.ts @@ -11,7 +11,9 @@ import { InitializationMode } from './types'; */ export class DatadogProviderConfiguration extends CoreConfiguration { /** - * If set to ASYNC, the initialization will be delayed until all animations are completed. + * If set to ASYNC, the native initialization will run when the + * main thread is idle (via `requestIdleCallback` on RN >= 0.76, or + * `InteractionManager.runAfterInteractions` on earlier versions) */ public initializationMode: InitializationMode = InitializationMode.SYNC; } diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/__utils__/renderWithProvider.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/__utils__/renderWithProvider.tsx index 80069c5b1..3ae01359a 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/__utils__/renderWithProvider.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/__utils__/renderWithProvider.tsx @@ -5,7 +5,7 @@ */ import { render } from '@testing-library/react-native'; -import { Animated, Button, InteractionManager, Text, View } from 'react-native'; +import { Animated, Button, Text, View } from 'react-native'; import React, { useState } from 'react'; import { DatadogProviderConfiguration } from '../../../../config/DatadogProviderConfiguration'; @@ -109,14 +109,31 @@ export const renderWithProvider = (params?: { }; /** - * Mocks an animation for InteractionManager.runAfterInteractions. Returns - * a function to be called to finish the animation + * Mocks requestIdleCallback so that scheduled callbacks only run when + * {@link flushIdleCallbacks} is called. Restores the original value on + * {@link restore}. */ -export const mockAnimation = () => { - const fakeAnimationHandle = InteractionManager.createInteractionHandle(); +export const mockIdleCallback = () => { + const callbacks: Array<() => void> = []; + const original = (globalThis as Record) + .requestIdleCallback; + + (globalThis as Record).requestIdleCallback = ( + cb: () => void + ) => { + callbacks.push(cb); + return callbacks.length; + }; return { - finishAnimation: () => - InteractionManager.clearInteractionHandle(fakeAnimationHandle) + flushIdleCallbacks: () => { + callbacks.splice(0).forEach(cb => cb()); + }, + restore: () => { + (globalThis as Record< + string, + unknown + >).requestIdleCallback = original; + } }; }; diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx index 76943f040..1380fb0c2 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initialization.test.tsx @@ -22,7 +22,7 @@ import { import { getDefaultConfiguration, - mockAnimation, + mockIdleCallback, renderWithProvider } from './__utils__/renderWithProvider'; @@ -190,20 +190,24 @@ describe('DatadogProvider', () => { it('runs after initialization when ASYNC initialization', async () => { const onInitialization = jest.fn(); - const { finishAnimation } = mockAnimation(); - const configuration = getDefaultConfiguration(); - configuration.initializationMode = InitializationMode.ASYNC; - const { getByText } = renderWithProvider({ - onInitialization, - configuration - }); - getByText('I am a test application'); - await flushPromises(); - expect(onInitialization).not.toHaveBeenCalled(); - - finishAnimation(); - await flushPromises(); - expect(onInitialization).toHaveBeenCalledTimes(1); + const idle = mockIdleCallback(); + try { + const configuration = getDefaultConfiguration(); + configuration.initializationMode = InitializationMode.ASYNC; + const { getByText } = renderWithProvider({ + onInitialization, + configuration + }); + getByText('I am a test application'); + await flushPromises(); + expect(onInitialization).not.toHaveBeenCalled(); + + idle.flushIdleCallbacks(); + await flushPromises(); + expect(onInitialization).toHaveBeenCalledTimes(1); + } finally { + idle.restore(); + } }); it('runs after initialization when partial initialization', async () => { const onInitialization = jest.fn(); diff --git a/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx b/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx index 75619b6d7..cfc9883b0 100644 --- a/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx +++ b/packages/core/src/sdk/DatadogProvider/__tests__/initializationModes.test.tsx @@ -5,7 +5,7 @@ */ import { fireEvent } from '@testing-library/react-native'; -import { NativeModules } from 'react-native'; +import { InteractionManager, NativeModules } from 'react-native'; import { DdSdkReactNative } from '../../../DdSdkReactNative'; import { InitializationMode } from '../../../config/types'; @@ -22,7 +22,7 @@ import { import { getDefaultConfiguration, - mockAnimation, + mockIdleCallback, renderWithProvider, renderWithProviderAndAnimation } from './__utils__/renderWithProvider'; @@ -70,70 +70,101 @@ describe('DatadogProvider', () => { expect(NativeModules.DdRum.addAction).toHaveBeenCalledTimes(1); }); - it('initializes the SDK before animations are done', async () => { - const { finishAnimation } = mockAnimation(); - renderWithProvider(); + it('initializes the SDK without waiting for idle callback', async () => { + const idle = mockIdleCallback(); + try { + const configuration = getDefaultConfiguration(); + configuration.initializationMode = InitializationMode.SYNC; + renderWithProvider({ configuration }); - await flushPromises(); - expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); - finishAnimation(); + await flushPromises(); + + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + + idle.flushIdleCallbacks(); + await flushPromises(); + + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + } finally { + idle.restore(); + } }); }); describe('initializationMode ASYNC', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - afterEach(() => { - jest.useRealTimers(); - }); - it('starts auto-instrumentation after animations are done (with real Animation)', async () => { - const configuration = getDefaultConfiguration(); - configuration.initializationMode = InitializationMode.ASYNC; - - const { getByText } = renderWithProviderAndAnimation({ - configuration - }); - await flushPromises(); - expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(700); - await flushPromises(); - expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); - const button = getByText('test button'); - fireEvent(button, 'press', { - _targetInst: { - props: { - 'dd-action-name': 'press button' + it('initializes the SDK when the idle callback fires', async () => { + const idle = mockIdleCallback(); + try { + const configuration = getDefaultConfiguration(); + configuration.initializationMode = InitializationMode.ASYNC; + + const { getByText } = renderWithProvider({ configuration }); + await flushPromises(); + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(0); + + idle.flushIdleCallbacks(); + await flushPromises(); + + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + const button = getByText('test button'); + fireEvent(button, 'press', { + _targetInst: { + props: { + 'dd-action-name': 'press button' + } } - } - }); - expect(NativeModules.DdRum.addAction).toHaveBeenCalledTimes(1); + }); + expect(NativeModules.DdRum.addAction).toHaveBeenCalledTimes(1); + } finally { + idle.restore(); + } }); - it('starts auto-instrumentation after animations are done (with InteractionManager)', async () => { - jest.useRealTimers(); - const configuration = getDefaultConfiguration(); - configuration.initializationMode = InitializationMode.ASYNC; + it('defers initialization while animations are running', async () => { + const idle = mockIdleCallback(); + try { + const configuration = getDefaultConfiguration(); + configuration.initializationMode = InitializationMode.ASYNC; - const { finishAnimation } = mockAnimation(); - const { getByText } = renderWithProvider({ configuration }); + renderWithProviderAndAnimation({ configuration }); + await flushPromises(); + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(0); - await flushPromises(); - expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(0); + idle.flushIdleCallbacks(); + await flushPromises(); - finishAnimation(); - await flushPromises(); + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + } finally { + idle.restore(); + } + }); - expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); - const button = getByText('test button'); - fireEvent(button, 'press', { - _targetInst: { - props: { - 'dd-action-name': 'press button' - } - } - }); - expect(NativeModules.DdRum.addAction).toHaveBeenCalledTimes(1); + it('falls back to InteractionManager when requestIdleCallback is unavailable', async () => { + const original = (globalThis as Record) + .requestIdleCallback; + (globalThis as Record< + string, + unknown + >).requestIdleCallback = undefined; + try { + const configuration = getDefaultConfiguration(); + configuration.initializationMode = InitializationMode.ASYNC; + + const handle = InteractionManager.createInteractionHandle(); + renderWithProvider({ configuration }); + await flushPromises(); + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(0); + + InteractionManager.clearInteractionHandle(handle); + await flushPromises(); + + expect(NativeModules.DdSdk.initialize).toHaveBeenCalledTimes(1); + } finally { + (globalThis as Record< + string, + unknown + >).requestIdleCallback = original; + } }); }); From 4bab4cee82629f50844093022afbe965e0e28c1b Mon Sep 17 00:00:00 2001 From: Sergio Barrio Date: Fri, 17 Apr 2026 15:27:24 +0200 Subject: [PATCH 2/2] Cover rejection of initNative --- packages/core/src/DdSdkReactNative.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/DdSdkReactNative.tsx b/packages/core/src/DdSdkReactNative.tsx index f6f3c6230..881d42fbf 100644 --- a/packages/core/src/DdSdkReactNative.tsx +++ b/packages/core/src/DdSdkReactNative.tsx @@ -131,11 +131,11 @@ export class DdSdkReactNative { // We rely on requestIdleCallback for RN >= 0.76 to make sure that the SDK initialization // happens after rendering has finished if ((globalThis as Record).requestIdleCallback) { - return new Promise(resolve => { + return new Promise((resolve, reject) => { ((globalThis as unknown) as { requestIdleCallback: (cb: () => void) => void; }).requestIdleCallback(() => { - initNative().then(resolve); + initNative().then(resolve, reject); }); }); }