Skip to content

Commit 4ac3da4

Browse files
committed
PANA-7123 Add hit-test fallback for heatmap view resolution
1 parent 53e1add commit 4ac3da4

5 files changed

Lines changed: 189 additions & 12 deletions

File tree

packages/core/ios/Sources/DdRumImplementation.swift

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ public class DdRumImplementation: NSObject {
9595
lazy var heatmapIdentifierRegistry: HeatmapIdentifierRegistry? = heatmapIdentifierRegistryProvider()
9696
private let mainDispatchQueue: DispatchQueueType
9797
private let uiManager: RCTUIManager
98+
private let rootViewProvider: () -> UIView?
9899
private let heatmapIdentifierRegistryProvider: () -> HeatmapIdentifierRegistry?
99100
private let rumProvider: () -> RUMMonitorProtocol
100101
private let rumInternalProvider: () -> RUMMonitorInternalProtocol?
@@ -104,12 +105,14 @@ public class DdRumImplementation: NSObject {
104105
internal init(
105106
mainDispatchQueue: DispatchQueueType,
106107
uiManager: RCTUIManager,
108+
rootViewProvider: @escaping () -> UIView?,
107109
heatmapIdentifierRegistryProvider: @escaping () -> HeatmapIdentifierRegistry?,
108110
rumProvider: @escaping () -> RUMMonitorProtocol,
109111
rumInternalProvider: @escaping () -> RUMMonitorInternalProtocol?
110112
) {
111113
self.mainDispatchQueue = mainDispatchQueue
112114
self.uiManager = uiManager
115+
self.rootViewProvider = rootViewProvider
113116
self.heatmapIdentifierRegistryProvider = heatmapIdentifierRegistryProvider
114117
self.rumProvider = rumProvider
115118
self.rumInternalProvider = rumInternalProvider
@@ -120,6 +123,7 @@ public class DdRumImplementation: NSObject {
120123
self.init(
121124
mainDispatchQueue: DispatchQueue.main,
122125
uiManager: bridge.uiManager,
126+
rootViewProvider: { UIWindow.reactRootView() },
123127
heatmapIdentifierRegistryProvider: { CoreRegistry.default.heatmapIdentifierRegistry },
124128
rumProvider: { RUMMonitor.shared() },
125129
rumInternalProvider: { RUMMonitor.shared()._internal }
@@ -156,14 +160,17 @@ public class DdRumImplementation: NSObject {
156160
let touch,
157161
let reactTag = touch["reactTag"] as? NSNumber,
158162
let x = touch["x"] as? NSNumber,
159-
let y = touch["y"] as? NSNumber
163+
let y = touch["y"] as? NSNumber,
164+
let pageX = touch["pageX"] as? NSNumber,
165+
let pageY = touch["pageY"] as? NSNumber
160166
{
161167
addAction(
162168
at: Date(timeIntervalSince1970: timestampMs / 1_000),
163169
type: RUMActionType(from: type),
164170
name: name,
165171
reactTag: reactTag,
166172
location: .init(x: CGFloat(truncating: x), y: CGFloat(truncating: y)),
173+
pageLocation: .init(x: CGFloat(truncating: pageX), y: CGFloat(truncating: pageY)),
167174
attributes: castAttributesToSwift(context)
168175
)
169176
} else {
@@ -362,10 +369,19 @@ public class DdRumImplementation: NSObject {
362369
name: String,
363370
reactTag: NSNumber,
364371
location: CGPoint,
372+
pageLocation: CGPoint,
365373
attributes: [AttributeKey: AttributeValue]
366374
) {
367-
mainDispatchQueue.async { [uiManager, heatmapIdentifierRegistry, rumInternal] in
368-
let heatmapAttributes: HeatmapAttributes? = uiManager.view(forReactTag: reactTag).flatMap { view in
375+
mainDispatchQueue.async { [uiManager, rootViewProvider, heatmapIdentifierRegistry, rumInternal] in
376+
var location = location
377+
let view = uiManager.view(
378+
forReactTag: reactTag,
379+
location: &location,
380+
pageLocation: pageLocation,
381+
rootViewProvider: rootViewProvider
382+
)
383+
384+
let heatmapAttributes: HeatmapAttributes? = view.flatMap { view in
369385
guard let identifier = heatmapIdentifierRegistry?.heatmapIdentifier(for: ObjectIdentifier(view)) else {
370386
return nil
371387
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2016-Present Datadog, Inc.
5+
*/
6+
7+
import React
8+
import UIKit
9+
10+
internal extension UIWindow {
11+
/// Returns the React Native root view in the current app's key window, if any.
12+
static func reactRootView() -> UIView? {
13+
return UIApplication.shared.keyWindow?.findReactRootView()
14+
}
15+
16+
private func findReactRootView() -> UIView? {
17+
return findReactRootView(in: self)
18+
}
19+
20+
private func findReactRootView(in view: UIView) -> UIView? {
21+
if view.isReactRootView() {
22+
return view
23+
}
24+
for subview in view.subviews {
25+
if let root = findReactRootView(in: subview) {
26+
return root
27+
}
28+
}
29+
return nil
30+
}
31+
}
32+
33+
private extension UIApplication {
34+
var keyWindow: UIWindow? {
35+
if #available(iOS 15.0, *) {
36+
return connectedScenes
37+
.compactMap { $0 as? UIWindowScene }
38+
.first?
39+
.keyWindow
40+
} else if #available(iOS 13.0, *) {
41+
return connectedScenes
42+
.compactMap { $0 as? UIWindowScene }
43+
.first?
44+
.windows
45+
.first(where: { $0.isKeyWindow })
46+
} else {
47+
return windows.first(where: { $0.isKeyWindow })
48+
}
49+
}
50+
}
51+
52+
internal extension RCTUIManager {
53+
/// Returns the view for a given `reactTag`, with a hit-test fallback when the tag cannot be resolved.
54+
///
55+
/// Tries `view(forReactTag:)` first (fast path). If it returns `nil`, falls back to a hit-test
56+
/// on the RN root view using `pageLocation` and overwrites `location` with the tap point in
57+
/// the hit-tested view's coordinate space. This hardens the integration against RN potentially
58+
/// deprecating `reactTag` view lookup on Fabric.
59+
///
60+
/// - Parameters:
61+
/// - reactTag: The reactTag reported by the JS `GestureResponderEvent`.
62+
/// - location: On input, the tap location in the original target view's coordinate space
63+
/// (from `locationX/Y`). On output, the tap location in the resolved view's coordinate
64+
/// space — unchanged on the fast path, recomputed when the fallback is used.
65+
/// - pageLocation: The tap location in the RN root view's coordinate space (from `pageX/Y`).
66+
/// RN computes these by converting the touch point from window to root-view coordinates,
67+
/// so no additional conversion is needed before hit-testing.
68+
/// - rootViewProvider: Closure returning the RN root view to hit-test against.
69+
func view(
70+
forReactTag reactTag: NSNumber,
71+
location: inout CGPoint,
72+
pageLocation: CGPoint,
73+
rootViewProvider: () -> UIView?
74+
) -> UIView? {
75+
if let view = view(forReactTag: reactTag) {
76+
return view
77+
}
78+
guard
79+
let rootView = rootViewProvider(),
80+
let hitView = rootView.hitTest(pageLocation, with: nil)
81+
else {
82+
return nil
83+
}
84+
location = rootView.convert(pageLocation, to: hitView)
85+
return hitView
86+
}
87+
}

packages/core/ios/Tests/DdRumTests.swift

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import React
1414
internal class DdRumTests: XCTestCase {
1515
private let mockNativeRUM = MockRUMMonitor()
1616
private let mockUIManager = MockUIManager()
17+
private let mockRootView = MockRootView()
1718
private let mockHeatmapIdentifierRegistry = MockHeatmapIdentifierRegistry()
1819
private var rum: DdRumImplementation! // swiftlint:disable:this implicitly_unwrapped_optional
19-
20+
2021
private func mockResolve(args: Any?) {}
2122
private func mockReject(args: String?, arg: String?, err: Error?) {}
2223

@@ -27,6 +28,7 @@ internal class DdRumTests: XCTestCase {
2728
rum = DdRumImplementation(
2829
mainDispatchQueue: DispatchQueueMock(),
2930
uiManager: self.mockUIManager,
31+
rootViewProvider: { self.mockRootView },
3032
heatmapIdentifierRegistryProvider: { self.mockHeatmapIdentifierRegistry },
3133
rumProvider: { self.mockNativeRUM },
3234
rumInternalProvider: { self.mockNativeRUM._internalMock }
@@ -40,6 +42,7 @@ internal class DdRumTests: XCTestCase {
4042
let rum = DdRumImplementation(
4143
mainDispatchQueue: DispatchQueueMock(),
4244
uiManager: MockUIManager(),
45+
rootViewProvider: { nil },
4346
heatmapIdentifierRegistryProvider: { nil },
4447
rumProvider: { [unowned self] in
4548
expectation.fulfill()
@@ -141,7 +144,9 @@ internal class DdRumTests: XCTestCase {
141144
let touch: NSDictionary = [
142145
"reactTag": 42,
143146
"x": 10.0,
144-
"y": 20.0
147+
"y": 20.0,
148+
"pageX": 100.0,
149+
"pageY": 200.0
145150
]
146151

147152
// When
@@ -172,13 +177,24 @@ internal class DdRumTests: XCTestCase {
172177
)
173178
}
174179

175-
func testAddActionWithTouchAndUnknownReactTag() throws {
180+
func testAddActionFallsBackToHitTestWhenReactTagNotFound() throws {
181+
// Given
182+
let hitView = UIView(frame: CGRect(x: 50, y: 100, width: 200, height: 50))
183+
mockRootView.addSubview(hitView)
184+
mockRootView.hitTestResult = hitView
185+
186+
let identifier = HeatmapIdentifier(rawValue: "abc123")
187+
mockHeatmapIdentifierRegistry.identifiers[ObjectIdentifier(hitView)] = identifier
188+
176189
let touch: NSDictionary = [
177190
"reactTag": 999,
178191
"x": 10.0,
179-
"y": 20.0
192+
"y": 20.0,
193+
"pageX": 130.0,
194+
"pageY": 220.0
180195
]
181-
196+
197+
// When
182198
rum.addAction(
183199
type: "tap",
184200
name: "tap action",
@@ -188,7 +204,49 @@ internal class DdRumTests: XCTestCase {
188204
resolve: mockResolve,
189205
reject: mockReject
190206
)
191-
207+
208+
// Then
209+
XCTAssertEqual(mockNativeRUM.calledMethods.count, 1)
210+
XCTAssertEqual(mockRootView.receivedHitTestPoints, [CGPoint(x: 130, y: 220)])
211+
XCTAssertEqual(
212+
mockNativeRUM.calledMethods.last,
213+
.addAction(
214+
time: Date(timeIntervalSince1970: randomTimestamp / 1_000),
215+
type: .tap,
216+
name: "tap action",
217+
heatmapAttributes: HeatmapAttributes(
218+
identifier: identifier,
219+
size: CGSize(width: 200, height: 50),
220+
location: CGPoint(x: 80, y: 120)
221+
)
222+
)
223+
)
224+
}
225+
226+
func testAddActionWithTouchWhenFallbackHitTestMisses() throws {
227+
// Given
228+
mockRootView.hitTestResult = nil
229+
230+
let touch: NSDictionary = [
231+
"reactTag": 999,
232+
"x": 10.0,
233+
"y": 20.0,
234+
"pageX": 100.0,
235+
"pageY": 200.0
236+
]
237+
238+
// When
239+
rum.addAction(
240+
type: "tap",
241+
name: "tap action",
242+
touch: touch,
243+
context: [:],
244+
timestampMs: randomTimestamp,
245+
resolve: mockResolve,
246+
reject: mockReject
247+
)
248+
249+
// Then
192250
XCTAssertEqual(mockNativeRUM.calledMethods.count, 1)
193251
XCTAssertEqual(
194252
mockNativeRUM.calledMethods.last,
@@ -444,6 +502,16 @@ private class MockUIManager: RCTUIManager {
444502
}
445503
}
446504

505+
private class MockRootView: UIView {
506+
var hitTestResult: UIView?
507+
var receivedHitTestPoints: [CGPoint] = []
508+
509+
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
510+
receivedHitTestPoints.append(point)
511+
return hitTestResult
512+
}
513+
}
514+
447515
private final class MockHeatmapIdentifierRegistry: @unchecked Sendable, HeatmapIdentifierRegistry {
448516
@ReadWriteLock
449517
var identifiers: [ObjectIdentifier: HeatmapIdentifier] = [:]

packages/core/src/rum/DdRum.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ type TouchData = {
5656
reactTag: number;
5757
x: number;
5858
y: number;
59+
pageX: number;
60+
pageY: number;
5961
};
6062

6163
const touchDataFromEvent = (
@@ -68,7 +70,9 @@ const touchDataFromEvent = (
6870
return {
6971
reactTag: Number(nativeEvent.target),
7072
x: nativeEvent.locationX,
71-
y: nativeEvent.locationY
73+
y: nativeEvent.locationY,
74+
pageX: nativeEvent.pageX,
75+
pageY: nativeEvent.pageY
7276
};
7377
};
7478

packages/core/src/rum/__tests__/DdRum.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1713,7 +1713,9 @@ describe('DdRum', () => {
17131713
nativeEvent: {
17141714
target: 42,
17151715
locationX: 10,
1716-
locationY: 20
1716+
locationY: 20,
1717+
pageX: 100,
1718+
pageY: 200
17171719
}
17181720
} as unknown) as GestureResponderEvent;
17191721

@@ -1728,7 +1730,7 @@ describe('DdRum', () => {
17281730
expect(NativeModules.DdRum.addAction).toHaveBeenCalledWith(
17291731
'TAP',
17301732
'tap button',
1731-
{ reactTag: 42, x: 10, y: 20 },
1733+
{ reactTag: 42, x: 10, y: 20, pageX: 100, pageY: 200 },
17321734
{},
17331735
expect.anything()
17341736
);

0 commit comments

Comments
 (0)