From b4ccd0ae4a8a944c509a18f24c63824700456061 Mon Sep 17 00:00:00 2001 From: Hugo Silva Date: Mon, 23 Feb 2026 12:06:28 +0100 Subject: [PATCH] fix: resolve RUM action tracking for libraries wrapping onPress event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some third-party libraries (e.g. react-native-ui-lib) change the onPress callback signature by wrapping the native GestureResponderEvent inside a props object: onPress({...componentProps, event: GestureResponderEvent}). The SDK checks args[0]._targetInst to identify the tapped element, which only exists on the native event — not on the wrapper object — causing actions to be silently dropped. This adds a fallback in DdEventsInterceptor.interceptOnPress that checks args[0].event._targetInst when args[0]._targetInst is not present, allowing the SDK to extract the native event from the wrapper. --- .../DdEventsInterceptor.test.tsx | 89 +++++++++++++++++++ .../DdEventsInterceptor.tsx | 28 +++++- 2 files changed, 114 insertions(+), 3 deletions(-) diff --git a/packages/core/src/__tests__/rum/instrumentation/DdEventsInterceptor.test.tsx b/packages/core/src/__tests__/rum/instrumentation/DdEventsInterceptor.test.tsx index 7d9c24687..c027c3144 100644 --- a/packages/core/src/__tests__/rum/instrumentation/DdEventsInterceptor.test.tsx +++ b/packages/core/src/__tests__/rum/instrumentation/DdEventsInterceptor.test.tsx @@ -229,6 +229,95 @@ it('M send a RUM Action event W interceptOnPress { no accessibilityLabel, no ele expect(DdRum.addAction.mock.calls[0][1]).toBe(UNKNOWN_TARGET_NAME); }); +it('M send a RUM Action event W interceptOnPress { event nested in props wrapper (react-native-ui-lib pattern) } ', async () => { + // GIVEN + const fakeAccessibilityLabel = 'wrapped_target'; + const fakeEvent = { + _targetInst: { + memoizedProps: { + accessibilityLabel: fakeAccessibilityLabel + } + } + }; + const fakeArguments = { + onPress: jest.fn(), + someOtherProp: 'value', + event: fakeEvent + }; + + // WHEN + testedEventsInterceptor.interceptOnPress(fakeArguments); + + // THEN + expect(DdRum.addAction.mock.calls.length).toBe(1); + expect(DdRum.addAction.mock.calls[0][0]).toBe(RumActionType.TAP); + expect(DdRum.addAction.mock.calls[0][1]).toBe(fakeAccessibilityLabel); + expect(DdRum.addAction.mock.calls[0][4]).toBe(fakeEvent); +}); + +it('M send a RUM Action event W interceptOnPress { event nested in props wrapper with dd-action-name } ', async () => { + // GIVEN + const fakeDdActionLabel = 'WrappedDdActionLabel'; + const fakeEvent = { + _targetInst: { + memoizedProps: { + 'dd-action-name': fakeDdActionLabel + } + } + }; + const fakeArguments = { + onPress: jest.fn(), + event: fakeEvent + }; + + // WHEN + testedEventsInterceptor.interceptOnPress(fakeArguments); + + // THEN + expect(DdRum.addAction.mock.calls.length).toBe(1); + expect(DdRum.addAction.mock.calls[0][0]).toBe(RumActionType.TAP); + expect(DdRum.addAction.mock.calls[0][1]).toBe(fakeDdActionLabel); + expect(DdRum.addAction.mock.calls[0][4]).toBe(fakeEvent); +}); + +it('M do nothing W interceptOnPress { props wrapper without nested event (incubator pattern) } ', async () => { + // GIVEN - react-native-ui-lib Incubator passes props without event + const fakeArguments = { + onPress: jest.fn(), + someOtherProp: 'value' + }; + + // WHEN + testedEventsInterceptor.interceptOnPress(fakeArguments); + + // THEN + expect(DdRum.addAction.mock.calls.length).toBe(0); + expect(InternalLog.log.mock.calls.length).toBe(1); + expect(InternalLog.log.mock.calls[0][0]).toBe( + DdEventsInterceptor.ACTION_EVENT_DROPPED_DEBUG_MESSAGE + ); + expect(InternalLog.log.mock.calls[0][1]).toBe(SdkVerbosity.DEBUG); +}); + +it('M do nothing W interceptOnPress { props wrapper with event that has no _targetInst } ', async () => { + // GIVEN - event property exists but is not a valid native event + const fakeArguments = { + onPress: jest.fn(), + event: { nativeEvent: { pageX: 0, pageY: 0 } } + }; + + // WHEN + testedEventsInterceptor.interceptOnPress(fakeArguments); + + // THEN + expect(DdRum.addAction.mock.calls.length).toBe(0); + expect(InternalLog.log.mock.calls.length).toBe(1); + expect(InternalLog.log.mock.calls[0][0]).toBe( + DdEventsInterceptor.ACTION_EVENT_DROPPED_DEBUG_MESSAGE + ); + expect(InternalLog.log.mock.calls[0][1]).toBe(SdkVerbosity.DEBUG); +}); + it('M do nothing W interceptOnPress { invalid arguments - empty object } ', async () => { // GIVEN const fakeArguments = {}; diff --git a/packages/core/src/rum/instrumentation/interactionTracking/DdEventsInterceptor.tsx b/packages/core/src/rum/instrumentation/interactionTracking/DdEventsInterceptor.tsx index d4185347f..6d9a88215 100644 --- a/packages/core/src/rum/instrumentation/interactionTracking/DdEventsInterceptor.tsx +++ b/packages/core/src/rum/instrumentation/interactionTracking/DdEventsInterceptor.tsx @@ -48,14 +48,15 @@ export class DdEventsInterceptor implements EventsInterceptor { } interceptOnPress(...args: any[]): void { - if (args.length > 0 && args[0] && args[0]._targetInst) { + const event = this.resolveNativeEvent(args); + if (event) { const currentTime = Date.now(); const timestampDifference = Math.abs( Date.now() - this.debouncingStartedTimestamp ); if (timestampDifference > DEBOUNCE_EVENT_THRESHOLD_IN_MS) { - const targetNode = args[0]._targetInst; - this.handleTargetEvent(targetNode, args[0]); + const targetNode = event._targetInst; + this.handleTargetEvent(targetNode, event); // we add an approximated 1 millisecond for the execution time of the `handleTargetEvent` function this.debouncingStartedTimestamp = currentTime + HANDLE_EVENT_APP_EXECUTION_TIME_IN_MS; @@ -68,6 +69,27 @@ export class DdEventsInterceptor implements EventsInterceptor { } } + /** + * Resolves the native GestureResponderEvent from the onPress arguments. + * + * Some third-party libraries (e.g. react-native-ui-lib) wrap the native + * event inside a props object: `onPress({...componentProps, event})`. + * This method checks for `_targetInst` on `args[0]` first (standard RN), + * then falls back to `args[0].event` for the wrapped pattern. + */ + private resolveNativeEvent(args: any[]): any | null { + if (args.length === 0 || !args[0]) { + return null; + } + if (args[0]._targetInst) { + return args[0]; + } + if (args[0].event && args[0].event._targetInst) { + return args[0].event; + } + return null; + } + private handleTargetEvent(targetNode: any, event: unknown) { if (targetNode) { const resolvedTargetName = this.resolveTargetName(targetNode);