From 0c35cd3b84aeb7bcef099789977591236eaf7eaa Mon Sep 17 00:00:00 2001 From: Guille Gonzalez Date: Wed, 22 Apr 2026 16:49:25 +0200 Subject: [PATCH] PANA-7123 Add hit-test fallback for heatmap view resolution --- benchmarks/ios/Podfile.lock | 2 +- .../SessionReplay/sessionReplayScenario.tsx | 1 + .../ios/Sources/DdRumImplementation.swift | 22 ++++- .../core/ios/Sources/RNViewResolution.swift | 88 +++++++++++++++++++ packages/core/ios/Tests/DdRumTests.swift | 80 +++++++++++++++-- packages/core/src/rum/DdRum.ts | 6 +- packages/core/src/rum/__tests__/DdRum.test.ts | 6 +- 7 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 packages/core/ios/Sources/RNViewResolution.swift diff --git a/benchmarks/ios/Podfile.lock b/benchmarks/ios/Podfile.lock index c578c57e3..4151487ba 100644 --- a/benchmarks/ios/Podfile.lock +++ b/benchmarks/ios/Podfile.lock @@ -2095,7 +2095,7 @@ SPEC CHECKSUMS: DatadogLogs: 20381f731050c500c85d36f4799cf0f28a56915e DatadogRUM: 4f863c5330d85d6b54bb372d830929ad3b5006ab DatadogSDKReactNative: 2d03d9524f43e93a40a466b53982bd303b045e1a - DatadogSDKReactNativeSessionReplay: 9225e86628c76a06d11c80200e06f10738018e59 + DatadogSDKReactNativeSessionReplay: 1127faae3e8ee11e6b4b480053e1fbfe382f653d DatadogSDKReactNativeWebView: 25996d34b0b283c37d98e2e079e00b925d09edd1 DatadogSessionReplay: aef317ccd62a089691899bc7153177deec3c66d6 DatadogTrace: c51a2bde28197cb1bab50031c83a82e3a1dbcf30 diff --git a/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx b/benchmarks/src/scenario/SessionReplay/sessionReplayScenario.tsx index 6d72cd601..617873f70 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, + enableHeatmaps: true, }).then(() => { setIsReady(true); console.log("Session replay - start recording"); diff --git a/packages/core/ios/Sources/DdRumImplementation.swift b/packages/core/ios/Sources/DdRumImplementation.swift index c83098949..fec42b407 100644 --- a/packages/core/ios/Sources/DdRumImplementation.swift +++ b/packages/core/ios/Sources/DdRumImplementation.swift @@ -95,6 +95,7 @@ public class DdRumImplementation: NSObject { lazy var heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = heatmapIdentifierRegistryProvider() private let mainDispatchQueue: DispatchQueueType private let uiManager: RCTUIManager + private let rootViewProvider: () -> UIView? private let heatmapIdentifierRegistryProvider: () -> HeatmapIdentifierRegistry? private let rumProvider: () -> RUMMonitorProtocol private let rumInternalProvider: () -> RUMMonitorInternalProtocol? @@ -104,12 +105,14 @@ public class DdRumImplementation: NSObject { internal init( mainDispatchQueue: DispatchQueueType, uiManager: RCTUIManager, + rootViewProvider: @escaping () -> UIView?, heatmapIdentifierRegistryProvider: @escaping () -> HeatmapIdentifierRegistry?, rumProvider: @escaping () -> RUMMonitorProtocol, rumInternalProvider: @escaping () -> RUMMonitorInternalProtocol? ) { self.mainDispatchQueue = mainDispatchQueue self.uiManager = uiManager + self.rootViewProvider = rootViewProvider self.heatmapIdentifierRegistryProvider = heatmapIdentifierRegistryProvider self.rumProvider = rumProvider self.rumInternalProvider = rumInternalProvider @@ -120,6 +123,7 @@ public class DdRumImplementation: NSObject { self.init( mainDispatchQueue: DispatchQueue.main, uiManager: bridge.uiManager, + rootViewProvider: { UIWindow.reactRootView() }, heatmapIdentifierRegistryProvider: { CoreRegistry.default.heatmapIdentifierRegistry }, rumProvider: { RUMMonitor.shared() }, rumInternalProvider: { RUMMonitor.shared()._internal } @@ -156,7 +160,9 @@ public class DdRumImplementation: NSObject { let touch, let reactTag = touch["reactTag"] as? NSNumber, let x = touch["x"] as? NSNumber, - let y = touch["y"] as? NSNumber + let y = touch["y"] as? NSNumber, + let pageX = touch["pageX"] as? NSNumber, + let pageY = touch["pageY"] as? NSNumber { addAction( at: Date(timeIntervalSince1970: timestampMs / 1_000), @@ -164,6 +170,7 @@ public class DdRumImplementation: NSObject { name: name, reactTag: reactTag, location: .init(x: CGFloat(truncating: x), y: CGFloat(truncating: y)), + pageLocation: .init(x: CGFloat(truncating: pageX), y: CGFloat(truncating: pageY)), attributes: castAttributesToSwift(context) ) } else { @@ -362,10 +369,19 @@ public class DdRumImplementation: NSObject { name: String, reactTag: NSNumber, location: CGPoint, + pageLocation: CGPoint, attributes: [AttributeKey: AttributeValue] ) { - mainDispatchQueue.async { [uiManager, heatmapIdentifierRegistry, rumInternal] in - let heatmapAttributes: HeatmapAttributes? = uiManager.view(forReactTag: reactTag).flatMap { view in + mainDispatchQueue.async { [uiManager, rootViewProvider, heatmapIdentifierRegistry, rumInternal] in + var location = location + let view = uiManager.view( + forReactTag: reactTag, + location: &location, + pageLocation: pageLocation, + rootViewProvider: rootViewProvider + ) + + let heatmapAttributes: HeatmapAttributes? = view.flatMap { view in guard let identifier = heatmapIdentifierRegistry?.heatmapIdentifier(for: ObjectIdentifier(view)) else { return nil } diff --git a/packages/core/ios/Sources/RNViewResolution.swift b/packages/core/ios/Sources/RNViewResolution.swift new file mode 100644 index 000000000..6a21991af --- /dev/null +++ b/packages/core/ios/Sources/RNViewResolution.swift @@ -0,0 +1,88 @@ +/* + * 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 React +import UIKit + +internal extension UIWindow { + /// Returns the React Native root view in the current app's key window, if any. + static func reactRootView() -> UIView? { + return UIApplication.shared.activeKeyWindow?.findReactRootView() + } + + private func findReactRootView() -> UIView? { + return findReactRootView(in: self) + } + + private func findReactRootView(in view: UIView) -> UIView? { + if view.isReactRootView() { + return view + } + for subview in view.subviews { + if let root = findReactRootView(in: subview) { + return root + } + } + return nil + } +} + +private extension UIApplication { + var activeKeyWindow: UIWindow? { + if #available(iOS 13.0, *) { + let activeScene = connectedScenes + .compactMap { $0 as? UIWindowScene } + .first(where: { $0.activationState == .foregroundActive }) + if #available(iOS 15.0, *) { + return activeScene?.keyWindow + } + return activeScene?.windows.first(where: { $0.isKeyWindow }) + } else { + return windows.first(where: { $0.isKeyWindow }) + } + } +} + +internal extension RCTUIManager { + /// Returns the view for a given `reactTag`, with a hit-test fallback when the tag cannot be resolved. + /// + /// Tries `view(forReactTag:)` first (fast path). If it returns `nil`, falls back to a hit-test + /// on the RN root view using `pageLocation` and overwrites `location` with the tap point in + /// the hit-tested view's coordinate space. This hardens the integration against RN potentially + /// deprecating `reactTag` view lookup on Fabric. + /// + /// - Parameters: + /// - reactTag: The reactTag reported by the JS `GestureResponderEvent`. + /// - location: On input, the tap location in the original target view's coordinate space + /// (from `locationX/Y`). On output, the tap location in the resolved view's coordinate + /// space — unchanged on the fast path, recomputed when the fallback is used. + /// - pageLocation: The tap location in the RN root view's coordinate space (from `pageX/Y`). + /// RN computes these by converting the touch point from window to root-view coordinates, + /// so no additional conversion is needed before hit-testing. + /// - rootViewProvider: Closure returning the RN root view to hit-test against. + func view( + forReactTag reactTag: NSNumber, + location: inout CGPoint, + pageLocation: CGPoint, + rootViewProvider: () -> UIView? + ) -> UIView? { + if let view = view(forReactTag: reactTag) { + return view + } + DdTelemetry.telemetryDebug( + id: "datadog_react_native:heatmap_fallback", + message: "Heatmap view resolution fell back to hit-test" + ) + guard + let rootView = rootViewProvider(), + let hitView = rootView.hitTest(pageLocation, with: nil) + else { + return nil + } + location = rootView.convert(pageLocation, to: hitView) + return hitView + } +} diff --git a/packages/core/ios/Tests/DdRumTests.swift b/packages/core/ios/Tests/DdRumTests.swift index cc9ff5751..0ede8766d 100644 --- a/packages/core/ios/Tests/DdRumTests.swift +++ b/packages/core/ios/Tests/DdRumTests.swift @@ -14,9 +14,10 @@ import React internal class DdRumTests: XCTestCase { private let mockNativeRUM = MockRUMMonitor() private let mockUIManager = MockUIManager() + private let mockRootView = MockRootView() private let mockHeatmapIdentifierRegistry = MockHeatmapIdentifierRegistry() private var rum: DdRumImplementation! // swiftlint:disable:this implicitly_unwrapped_optional - + private func mockResolve(args: Any?) {} private func mockReject(args: String?, arg: String?, err: Error?) {} @@ -27,6 +28,7 @@ internal class DdRumTests: XCTestCase { rum = DdRumImplementation( mainDispatchQueue: DispatchQueueMock(), uiManager: self.mockUIManager, + rootViewProvider: { self.mockRootView }, heatmapIdentifierRegistryProvider: { self.mockHeatmapIdentifierRegistry }, rumProvider: { self.mockNativeRUM }, rumInternalProvider: { self.mockNativeRUM._internalMock } @@ -40,6 +42,7 @@ internal class DdRumTests: XCTestCase { let rum = DdRumImplementation( mainDispatchQueue: DispatchQueueMock(), uiManager: MockUIManager(), + rootViewProvider: { nil }, heatmapIdentifierRegistryProvider: { nil }, rumProvider: { [unowned self] in expectation.fulfill() @@ -141,7 +144,9 @@ internal class DdRumTests: XCTestCase { let touch: NSDictionary = [ "reactTag": 42, "x": 10.0, - "y": 20.0 + "y": 20.0, + "pageX": 100.0, + "pageY": 200.0 ] // When @@ -172,13 +177,24 @@ internal class DdRumTests: XCTestCase { ) } - func testAddActionWithTouchAndUnknownReactTag() throws { + func testAddActionFallsBackToHitTestWhenReactTagNotFound() throws { + // Given + let hitView = UIView(frame: CGRect(x: 50, y: 100, width: 200, height: 50)) + mockRootView.addSubview(hitView) + mockRootView.hitTestResult = hitView + + let identifier = HeatmapIdentifier(rawValue: "abc123") + mockHeatmapIdentifierRegistry.identifiers[ObjectIdentifier(hitView)] = identifier + let touch: NSDictionary = [ "reactTag": 999, "x": 10.0, - "y": 20.0 + "y": 20.0, + "pageX": 130.0, + "pageY": 220.0 ] - + + // When rum.addAction( type: "tap", name: "tap action", @@ -188,7 +204,49 @@ internal class DdRumTests: XCTestCase { resolve: mockResolve, reject: mockReject ) - + + // Then + XCTAssertEqual(mockNativeRUM.calledMethods.count, 1) + XCTAssertEqual(mockRootView.receivedHitTestPoints, [CGPoint(x: 130, y: 220)]) + XCTAssertEqual( + mockNativeRUM.calledMethods.last, + .addAction( + time: Date(timeIntervalSince1970: randomTimestamp / 1_000), + type: .tap, + name: "tap action", + heatmapAttributes: HeatmapAttributes( + identifier: identifier, + size: CGSize(width: 200, height: 50), + location: CGPoint(x: 80, y: 120) + ) + ) + ) + } + + func testAddActionWithTouchWhenFallbackHitTestMisses() throws { + // Given + mockRootView.hitTestResult = nil + + let touch: NSDictionary = [ + "reactTag": 999, + "x": 10.0, + "y": 20.0, + "pageX": 100.0, + "pageY": 200.0 + ] + + // When + rum.addAction( + type: "tap", + name: "tap action", + touch: touch, + context: [:], + timestampMs: randomTimestamp, + resolve: mockResolve, + reject: mockReject + ) + + // Then XCTAssertEqual(mockNativeRUM.calledMethods.count, 1) XCTAssertEqual( mockNativeRUM.calledMethods.last, @@ -444,6 +502,16 @@ private class MockUIManager: RCTUIManager { } } +private class MockRootView: UIView { + var hitTestResult: UIView? + var receivedHitTestPoints: [CGPoint] = [] + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + receivedHitTestPoints.append(point) + return hitTestResult + } +} + private final class MockHeatmapIdentifierRegistry: @unchecked Sendable, HeatmapIdentifierRegistry { @ReadWriteLock var identifiers: [ObjectIdentifier: HeatmapIdentifier] = [:] diff --git a/packages/core/src/rum/DdRum.ts b/packages/core/src/rum/DdRum.ts index 120436950..9a7f15dae 100644 --- a/packages/core/src/rum/DdRum.ts +++ b/packages/core/src/rum/DdRum.ts @@ -56,6 +56,8 @@ type TouchData = { reactTag: number; x: number; y: number; + pageX: number; + pageY: number; }; const touchDataFromEvent = ( @@ -68,7 +70,9 @@ const touchDataFromEvent = ( return { reactTag: Number(nativeEvent.target), x: nativeEvent.locationX, - y: nativeEvent.locationY + y: nativeEvent.locationY, + pageX: nativeEvent.pageX, + pageY: nativeEvent.pageY }; }; diff --git a/packages/core/src/rum/__tests__/DdRum.test.ts b/packages/core/src/rum/__tests__/DdRum.test.ts index 2507b04e2..7d0a3688b 100644 --- a/packages/core/src/rum/__tests__/DdRum.test.ts +++ b/packages/core/src/rum/__tests__/DdRum.test.ts @@ -1713,7 +1713,9 @@ describe('DdRum', () => { nativeEvent: { target: 42, locationX: 10, - locationY: 20 + locationY: 20, + pageX: 100, + pageY: 200 } } as unknown) as GestureResponderEvent; @@ -1728,7 +1730,7 @@ describe('DdRum', () => { expect(NativeModules.DdRum.addAction).toHaveBeenCalledWith( 'TAP', 'tap button', - { reactTag: 42, x: 10, y: 20 }, + { reactTag: 42, x: 10, y: 20, pageX: 100, pageY: 200 }, {}, expect.anything() );