@repliqo/sdk-react-native 0.3.4 → 0.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -5,7 +5,9 @@ import { ScrollView, type ScrollViewProps } from 'react-native';
|
|
|
5
5
|
*
|
|
6
6
|
* 1. Reports scroll offset for accurate heatmap touch coords
|
|
7
7
|
* 2. Captures viewport tiles on scroll stops AND during continuous
|
|
8
|
-
* scroll for collaborative screen building
|
|
9
|
-
*
|
|
8
|
+
* scroll for collaborative screen building
|
|
9
|
+
* 3. Registers its window-relative bounds with the SDK so touches can
|
|
10
|
+
* be auto-classified as "content" (inside ScrollView) or "fixed"
|
|
11
|
+
* (outside, e.g. nav bars/headers)
|
|
10
12
|
*/
|
|
11
13
|
export declare const RepliqoScrollView: React.ForwardRefExoticComponent<ScrollViewProps & React.RefAttributes<ScrollView>>;
|
|
@@ -49,31 +49,100 @@ const THROTTLE_MS = 400;
|
|
|
49
49
|
* where the user never pauses long enough for the debounce to fire.
|
|
50
50
|
*/
|
|
51
51
|
const SCROLL_INTERVAL_MS = 1200;
|
|
52
|
+
/**
|
|
53
|
+
* During scroll, throttle window-bounds re-measurement to at most once per
|
|
54
|
+
* this many ms. Bounds rarely change while scrolling, but we still want to
|
|
55
|
+
* catch orientation changes / keyboard / overlay animations.
|
|
56
|
+
*/
|
|
57
|
+
const REMEASURE_THROTTLE_MS = 500;
|
|
58
|
+
let scrollViewIdCounter = 0;
|
|
59
|
+
const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
|
|
52
60
|
/**
|
|
53
61
|
* Drop-in replacement for React Native's ScrollView that:
|
|
54
62
|
*
|
|
55
63
|
* 1. Reports scroll offset for accurate heatmap touch coords
|
|
56
64
|
* 2. Captures viewport tiles on scroll stops AND during continuous
|
|
57
|
-
* scroll for collaborative screen building
|
|
58
|
-
*
|
|
65
|
+
* scroll for collaborative screen building
|
|
66
|
+
* 3. Registers its window-relative bounds with the SDK so touches can
|
|
67
|
+
* be auto-classified as "content" (inside ScrollView) or "fixed"
|
|
68
|
+
* (outside, e.g. nav bars/headers)
|
|
59
69
|
*/
|
|
60
70
|
exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
|
|
61
71
|
const debounceTimer = (0, react_1.useRef)(null);
|
|
62
72
|
const scrollIntervalTimer = (0, react_1.useRef)(null);
|
|
73
|
+
const initialLayoutTimer = (0, react_1.useRef)(null);
|
|
63
74
|
const lastCaptureTime = (0, react_1.useRef)(0);
|
|
64
75
|
const lastCaptureY = (0, react_1.useRef)(-999);
|
|
76
|
+
const lastMeasureTime = (0, react_1.useRef)(0);
|
|
65
77
|
const initialCaptured = (0, react_1.useRef)(false);
|
|
66
78
|
const realContentHeight = (0, react_1.useRef)(0);
|
|
79
|
+
const scrollViewId = (0, react_1.useRef)(nextScrollViewId());
|
|
80
|
+
const innerRef = (0, react_1.useRef)(null);
|
|
81
|
+
const isMounted = (0, react_1.useRef)(true);
|
|
67
82
|
const scrollState = (0, react_1.useRef)(null);
|
|
68
|
-
//
|
|
83
|
+
// Forward the inner ref to the parent's ref (if provided).
|
|
84
|
+
// IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
|
|
85
|
+
// ref callback with null mid-update, and clearing would break async
|
|
86
|
+
// measurement callbacks that fire after the ref reattaches.
|
|
87
|
+
const setRef = (0, react_1.useCallback)((node) => {
|
|
88
|
+
if (node !== null) {
|
|
89
|
+
innerRef.current = node;
|
|
90
|
+
}
|
|
91
|
+
if (typeof ref === 'function') {
|
|
92
|
+
ref(node);
|
|
93
|
+
}
|
|
94
|
+
else if (ref) {
|
|
95
|
+
ref.current = node;
|
|
96
|
+
}
|
|
97
|
+
}, [ref]);
|
|
98
|
+
/**
|
|
99
|
+
* Measure window-relative bounds and register with SDK.
|
|
100
|
+
* Idempotent + safe to call after unmount (guards via isMounted).
|
|
101
|
+
*/
|
|
102
|
+
const measureAndRegister = (0, react_1.useCallback)(() => {
|
|
103
|
+
if (!isMounted.current)
|
|
104
|
+
return;
|
|
105
|
+
const node = innerRef.current;
|
|
106
|
+
if (!node || typeof node.measureInWindow !== 'function')
|
|
107
|
+
return;
|
|
108
|
+
node.measureInWindow((x, y, width, height) => {
|
|
109
|
+
if (!isMounted.current)
|
|
110
|
+
return;
|
|
111
|
+
// RN sometimes returns 0/0 dims before the view is positioned —
|
|
112
|
+
// ignore those, the next onLayout/onScroll will re-measure.
|
|
113
|
+
if (width <= 0 || height <= 0)
|
|
114
|
+
return;
|
|
115
|
+
try {
|
|
116
|
+
client_1.AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
|
|
117
|
+
x, y, width, height,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch { }
|
|
121
|
+
});
|
|
122
|
+
}, []);
|
|
123
|
+
// Cleanup all timers + unregister + listeners on unmount
|
|
69
124
|
(0, react_1.useEffect)(() => {
|
|
125
|
+
const id = scrollViewId.current;
|
|
126
|
+
isMounted.current = true;
|
|
127
|
+
// Re-measure when keyboard appears/disappears (causes layout shift)
|
|
128
|
+
const showSub = react_native_1.Keyboard.addListener('keyboardDidShow', measureAndRegister);
|
|
129
|
+
const hideSub = react_native_1.Keyboard.addListener('keyboardDidHide', measureAndRegister);
|
|
70
130
|
return () => {
|
|
131
|
+
isMounted.current = false;
|
|
71
132
|
if (debounceTimer.current)
|
|
72
133
|
clearTimeout(debounceTimer.current);
|
|
73
134
|
if (scrollIntervalTimer.current)
|
|
74
135
|
clearInterval(scrollIntervalTimer.current);
|
|
136
|
+
if (initialLayoutTimer.current)
|
|
137
|
+
clearTimeout(initialLayoutTimer.current);
|
|
138
|
+
showSub.remove();
|
|
139
|
+
hideSub.remove();
|
|
140
|
+
try {
|
|
141
|
+
client_1.AppAnalytics.getInstance().unregisterScrollView(id);
|
|
142
|
+
}
|
|
143
|
+
catch { }
|
|
75
144
|
};
|
|
76
|
-
}, []);
|
|
145
|
+
}, [measureAndRegister]);
|
|
77
146
|
/**
|
|
78
147
|
* Core tile capture logic. Reads from refs (stable across renders).
|
|
79
148
|
* Checks throttle + delta before capturing.
|
|
@@ -81,6 +150,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
81
150
|
* @param force Skip throttle check (used for interval captures)
|
|
82
151
|
*/
|
|
83
152
|
const doCapture = (force = false) => {
|
|
153
|
+
if (!isMounted.current)
|
|
154
|
+
return;
|
|
84
155
|
const state = scrollState.current;
|
|
85
156
|
if (!state || state.viewportHeight <= 0)
|
|
86
157
|
return;
|
|
@@ -116,11 +187,20 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
116
187
|
};
|
|
117
188
|
const handleScroll = (0, react_1.useCallback)((event) => {
|
|
118
189
|
const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
|
|
119
|
-
//
|
|
190
|
+
// Update SDK with current scroll offset (both legacy + new registry)
|
|
120
191
|
try {
|
|
121
|
-
client_1.AppAnalytics.getInstance()
|
|
192
|
+
const sdk = client_1.AppAnalytics.getInstance();
|
|
193
|
+
sdk.setScrollOffset(contentOffset.y);
|
|
194
|
+
sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
|
|
122
195
|
}
|
|
123
196
|
catch { }
|
|
197
|
+
// Re-measure bounds occasionally during scroll — catches layout
|
|
198
|
+
// shifts from animations, keyboard, orientation changes, etc.
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
|
|
201
|
+
lastMeasureTime.current = now;
|
|
202
|
+
measureAndRegister();
|
|
203
|
+
}
|
|
124
204
|
// Update scroll state — use realContentHeight if available
|
|
125
205
|
const bestContentHeight = realContentHeight.current > 0
|
|
126
206
|
? realContentHeight.current
|
|
@@ -138,10 +218,12 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
138
218
|
startScrollInterval();
|
|
139
219
|
// Capture initial tile (Y~0) on first scroll event with real dims
|
|
140
220
|
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
141
|
-
|
|
221
|
+
if (initialLayoutTimer.current)
|
|
222
|
+
clearTimeout(initialLayoutTimer.current);
|
|
223
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
|
|
142
224
|
}
|
|
143
225
|
onScroll?.(event);
|
|
144
|
-
}, [onScroll]);
|
|
226
|
+
}, [onScroll, measureAndRegister]);
|
|
145
227
|
const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
|
|
146
228
|
// User lifted finger — capture immediately
|
|
147
229
|
stopScrollInterval();
|
|
@@ -158,7 +240,6 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
158
240
|
onMomentumScrollEnd?.(event);
|
|
159
241
|
}, [onMomentumScrollEnd]);
|
|
160
242
|
const handleLayout = (0, react_1.useCallback)((event) => {
|
|
161
|
-
// Capture initial tile after layout (gets real viewportHeight)
|
|
162
243
|
const { height } = event.nativeEvent.layout;
|
|
163
244
|
if (!initialCaptured.current && height > 0) {
|
|
164
245
|
scrollState.current = {
|
|
@@ -166,10 +247,17 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
166
247
|
viewportHeight: height,
|
|
167
248
|
contentHeight: realContentHeight.current || height,
|
|
168
249
|
};
|
|
169
|
-
|
|
250
|
+
// Clear any prior pending initial-capture timer before scheduling
|
|
251
|
+
if (initialLayoutTimer.current)
|
|
252
|
+
clearTimeout(initialLayoutTimer.current);
|
|
253
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
|
|
170
254
|
}
|
|
255
|
+
// Register window-relative bounds immediately (no setTimeout).
|
|
256
|
+
// measureInWindow itself is async; if it returns 0/0 dims, the
|
|
257
|
+
// next onLayout / onScroll will retry — no race window.
|
|
258
|
+
measureAndRegister();
|
|
171
259
|
onLayout?.(event);
|
|
172
|
-
}, [onLayout]);
|
|
260
|
+
}, [onLayout, measureAndRegister]);
|
|
173
261
|
/**
|
|
174
262
|
* Track the real content height from onContentSizeChange.
|
|
175
263
|
* This is more reliable than the first scroll event and ensures
|
|
@@ -183,6 +271,6 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
|
|
|
183
271
|
}
|
|
184
272
|
onContentSizeChange?.(w, h);
|
|
185
273
|
}, [onContentSizeChange]);
|
|
186
|
-
return (<react_native_1.ScrollView ref={
|
|
274
|
+
return (<react_native_1.ScrollView ref={setRef} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
|
|
187
275
|
});
|
|
188
276
|
exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
|
package/dist/core/client.d.ts
CHANGED
|
@@ -14,12 +14,38 @@ export declare class AppAnalytics {
|
|
|
14
14
|
private scrollOffsetY;
|
|
15
15
|
private logger;
|
|
16
16
|
private appStateSubscription;
|
|
17
|
+
/**
|
|
18
|
+
* Registry of active ScrollViews on the current screen.
|
|
19
|
+
* Each RepliqoScrollView registers its window-relative bounds and current
|
|
20
|
+
* scroll state. Used by trackTouch to auto-detect:
|
|
21
|
+
* - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
|
|
22
|
+
* scrollOffset, mark as content touch
|
|
23
|
+
* - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
|
|
24
|
+
*
|
|
25
|
+
* This makes heatmaps render fixed elements (nav bars, headers) at their
|
|
26
|
+
* viewport position, while still mapping scrollable content correctly.
|
|
27
|
+
*/
|
|
28
|
+
private scrollViewRegistry;
|
|
17
29
|
private constructor();
|
|
18
30
|
static init(config: SDKConfig): AppAnalytics;
|
|
19
31
|
static getInstance(): AppAnalytics;
|
|
20
32
|
static destroy(): void;
|
|
21
33
|
startSession(deviceId: string, deviceInfo?: DeviceInfo): Promise<string>;
|
|
22
34
|
endSession(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Track a touch event for heatmap analysis.
|
|
37
|
+
*
|
|
38
|
+
* @param x Touch X coordinate, in WINDOW coordinates (must match the
|
|
39
|
+
* coordinate system of `measureInWindow` — typically `pageX`
|
|
40
|
+
* from a React Native gesture event).
|
|
41
|
+
* @param y Touch Y coordinate, in WINDOW coordinates (must match the
|
|
42
|
+
* coordinate system of `measureInWindow` — typically `pageY`
|
|
43
|
+
* from a React Native gesture event). On Android with edge-to-edge
|
|
44
|
+
* enabled, ensure `pageY` excludes the status bar to stay
|
|
45
|
+
* consistent with `measureInWindow`.
|
|
46
|
+
* @param screenName Optional screen name (defaults to current screen).
|
|
47
|
+
* @param extra Optional extra data to attach to the event.
|
|
48
|
+
*/
|
|
23
49
|
trackTouch(x: number, y: number, screenName?: string, extra?: Record<string, any>): void;
|
|
24
50
|
trackNavigation(fromScreen: string, toScreen: string): void;
|
|
25
51
|
trackCustomEvent(eventName: string, data?: Record<string, any>): void;
|
|
@@ -29,8 +55,32 @@ export declare class AppAnalytics {
|
|
|
29
55
|
* or manually from the host app's ScrollView onScroll handler.
|
|
30
56
|
* The offset is added to touch Y coordinates for accurate heatmaps
|
|
31
57
|
* on scrollable screens.
|
|
58
|
+
*
|
|
59
|
+
* @deprecated Prefer registerScrollView + updateScrollViewOffset for
|
|
60
|
+
* auto-detection of fixed vs scrollable touches. Kept for backward compat.
|
|
32
61
|
*/
|
|
33
62
|
setScrollOffset(y: number): void;
|
|
63
|
+
/**
|
|
64
|
+
* Register a ScrollView's window-relative bounds with the SDK.
|
|
65
|
+
* Called by RepliqoScrollView on layout. Used to auto-detect whether
|
|
66
|
+
* a touch is on scrollable content or on fixed UI (nav bars, headers).
|
|
67
|
+
*/
|
|
68
|
+
registerScrollView(id: string, bounds: {
|
|
69
|
+
x: number;
|
|
70
|
+
y: number;
|
|
71
|
+
width: number;
|
|
72
|
+
height: number;
|
|
73
|
+
}): void;
|
|
74
|
+
/**
|
|
75
|
+
* Update the scroll offset for a previously-registered ScrollView.
|
|
76
|
+
* Called by RepliqoScrollView on every scroll event.
|
|
77
|
+
*/
|
|
78
|
+
updateScrollViewOffset(id: string, scrollOffsetY: number): void;
|
|
79
|
+
/**
|
|
80
|
+
* Remove a ScrollView from the registry. Called by RepliqoScrollView
|
|
81
|
+
* on unmount.
|
|
82
|
+
*/
|
|
83
|
+
unregisterScrollView(id: string): void;
|
|
34
84
|
/**
|
|
35
85
|
* Capture the current viewport as a tile for collaborative screen
|
|
36
86
|
* building. Called by RepliqoScrollView on scroll stops.
|
package/dist/core/client.js
CHANGED
|
@@ -18,6 +18,18 @@ class AppAnalytics {
|
|
|
18
18
|
this.currentScreenEnteredAt = null;
|
|
19
19
|
this.scrollOffsetY = 0;
|
|
20
20
|
this.appStateSubscription = null;
|
|
21
|
+
/**
|
|
22
|
+
* Registry of active ScrollViews on the current screen.
|
|
23
|
+
* Each RepliqoScrollView registers its window-relative bounds and current
|
|
24
|
+
* scroll state. Used by trackTouch to auto-detect:
|
|
25
|
+
* - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
|
|
26
|
+
* scrollOffset, mark as content touch
|
|
27
|
+
* - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
|
|
28
|
+
*
|
|
29
|
+
* This makes heatmaps render fixed elements (nav bars, headers) at their
|
|
30
|
+
* viewport position, while still mapping scrollable content correctly.
|
|
31
|
+
*/
|
|
32
|
+
this.scrollViewRegistry = new Map();
|
|
21
33
|
this.config = (0, config_1.resolveConfig)(config);
|
|
22
34
|
this.logger = new logger_1.Logger(this.config.debug);
|
|
23
35
|
this.apiClient = new api_client_1.ApiClient(config_1.REPLIQO_API_URL, this.config.apiKey, this.logger);
|
|
@@ -155,6 +167,20 @@ class AppAnalytics {
|
|
|
155
167
|
this.snapshotCapture.setSessionId(null);
|
|
156
168
|
}
|
|
157
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Track a touch event for heatmap analysis.
|
|
172
|
+
*
|
|
173
|
+
* @param x Touch X coordinate, in WINDOW coordinates (must match the
|
|
174
|
+
* coordinate system of `measureInWindow` — typically `pageX`
|
|
175
|
+
* from a React Native gesture event).
|
|
176
|
+
* @param y Touch Y coordinate, in WINDOW coordinates (must match the
|
|
177
|
+
* coordinate system of `measureInWindow` — typically `pageY`
|
|
178
|
+
* from a React Native gesture event). On Android with edge-to-edge
|
|
179
|
+
* enabled, ensure `pageY` excludes the status bar to stay
|
|
180
|
+
* consistent with `measureInWindow`.
|
|
181
|
+
* @param screenName Optional screen name (defaults to current screen).
|
|
182
|
+
* @param extra Optional extra data to attach to the event.
|
|
183
|
+
*/
|
|
158
184
|
trackTouch(x, y, screenName, extra) {
|
|
159
185
|
if (!this.sessionId) {
|
|
160
186
|
this.logger.warn('Cannot track touch: no active session');
|
|
@@ -163,14 +189,60 @@ class AppAnalytics {
|
|
|
163
189
|
if (!this.config.enableTouchTracking) {
|
|
164
190
|
return;
|
|
165
191
|
}
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
192
|
+
// Auto-detect: find which (if any) registered ScrollView contains
|
|
193
|
+
// this touch. If multiple match (nested ScrollViews, e.g. FlatList
|
|
194
|
+
// inside ScrollView), pick the SMALLEST by area — the most-nested
|
|
195
|
+
// one is the actual scroll context for that touch.
|
|
196
|
+
let bestMatchOffset = 0;
|
|
197
|
+
let bestMatchArea = Infinity;
|
|
198
|
+
let foundMatch = false;
|
|
199
|
+
for (const info of this.scrollViewRegistry.values()) {
|
|
200
|
+
const { bounds } = info;
|
|
201
|
+
if (x >= bounds.x &&
|
|
202
|
+
x <= bounds.x + bounds.width &&
|
|
203
|
+
y >= bounds.y &&
|
|
204
|
+
y <= bounds.y + bounds.height) {
|
|
205
|
+
const area = bounds.width * bounds.height;
|
|
206
|
+
if (area < bestMatchArea) {
|
|
207
|
+
bestMatchArea = area;
|
|
208
|
+
bestMatchOffset = info.scrollOffsetY;
|
|
209
|
+
foundMatch = true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
let activeScrollOffset;
|
|
214
|
+
let isFixed;
|
|
215
|
+
if (foundMatch) {
|
|
216
|
+
// Touch is inside a registered ScrollView → content touch
|
|
217
|
+
activeScrollOffset = bestMatchOffset;
|
|
218
|
+
isFixed = false;
|
|
219
|
+
}
|
|
220
|
+
else if (this.scrollViewRegistry.size > 0) {
|
|
221
|
+
// ScrollViews ARE registered, but this touch is outside all of them
|
|
222
|
+
// → it's on fixed UI (nav bar, header, etc.)
|
|
223
|
+
activeScrollOffset = 0;
|
|
224
|
+
isFixed = true;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// No ScrollViews registered at all. Two possible cases:
|
|
228
|
+
// (a) The screen has no scrollable content → touch is fixed
|
|
229
|
+
// (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
|
|
230
|
+
// Either way, marking as fixed is safer than guessing content
|
|
231
|
+
// position with stale scrollOffsetY from a previous screen.
|
|
232
|
+
activeScrollOffset = 0;
|
|
233
|
+
isFixed = true;
|
|
234
|
+
}
|
|
235
|
+
const adjustedY = y + activeScrollOffset;
|
|
170
236
|
const event = {
|
|
171
237
|
type: 'touch',
|
|
172
238
|
screenName: screenName || this.currentScreen || undefined,
|
|
173
|
-
data: {
|
|
239
|
+
data: {
|
|
240
|
+
x,
|
|
241
|
+
y: adjustedY,
|
|
242
|
+
scrollOffsetY: activeScrollOffset,
|
|
243
|
+
isFixed,
|
|
244
|
+
...extra,
|
|
245
|
+
},
|
|
174
246
|
timestamp: new Date().toISOString(),
|
|
175
247
|
};
|
|
176
248
|
this.eventQueue.add(event);
|
|
@@ -219,10 +291,42 @@ class AppAnalytics {
|
|
|
219
291
|
* or manually from the host app's ScrollView onScroll handler.
|
|
220
292
|
* The offset is added to touch Y coordinates for accurate heatmaps
|
|
221
293
|
* on scrollable screens.
|
|
294
|
+
*
|
|
295
|
+
* @deprecated Prefer registerScrollView + updateScrollViewOffset for
|
|
296
|
+
* auto-detection of fixed vs scrollable touches. Kept for backward compat.
|
|
222
297
|
*/
|
|
223
298
|
setScrollOffset(y) {
|
|
224
299
|
this.scrollOffsetY = y;
|
|
225
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Register a ScrollView's window-relative bounds with the SDK.
|
|
303
|
+
* Called by RepliqoScrollView on layout. Used to auto-detect whether
|
|
304
|
+
* a touch is on scrollable content or on fixed UI (nav bars, headers).
|
|
305
|
+
*/
|
|
306
|
+
registerScrollView(id, bounds) {
|
|
307
|
+
const existing = this.scrollViewRegistry.get(id);
|
|
308
|
+
this.scrollViewRegistry.set(id, {
|
|
309
|
+
bounds,
|
|
310
|
+
scrollOffsetY: existing?.scrollOffsetY ?? 0,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Update the scroll offset for a previously-registered ScrollView.
|
|
315
|
+
* Called by RepliqoScrollView on every scroll event.
|
|
316
|
+
*/
|
|
317
|
+
updateScrollViewOffset(id, scrollOffsetY) {
|
|
318
|
+
const existing = this.scrollViewRegistry.get(id);
|
|
319
|
+
if (existing) {
|
|
320
|
+
existing.scrollOffsetY = scrollOffsetY;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Remove a ScrollView from the registry. Called by RepliqoScrollView
|
|
325
|
+
* on unmount.
|
|
326
|
+
*/
|
|
327
|
+
unregisterScrollView(id) {
|
|
328
|
+
this.scrollViewRegistry.delete(id);
|
|
329
|
+
}
|
|
226
330
|
/**
|
|
227
331
|
* Capture the current viewport as a tile for collaborative screen
|
|
228
332
|
* building. Called by RepliqoScrollView on scroll stops.
|
|
@@ -282,6 +386,8 @@ class AppAnalytics {
|
|
|
282
386
|
this.currentScreen = screenName;
|
|
283
387
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
284
388
|
this.scrollOffsetY = 0;
|
|
389
|
+
// Clear the ScrollView registry — old screen's scrollviews are gone
|
|
390
|
+
this.scrollViewRegistry.clear();
|
|
285
391
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
286
392
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
287
393
|
// NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@repliqo/sdk-react-native",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Session replay & analytics SDK for React Native. Captures screenshots (including modals/alerts), navigation, screen visits, and custom events.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useRef, useEffect } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ScrollView,
|
|
4
|
+
Keyboard,
|
|
4
5
|
type ScrollViewProps,
|
|
5
6
|
type NativeSyntheticEvent,
|
|
6
7
|
type NativeScrollEvent,
|
|
@@ -20,36 +21,102 @@ const THROTTLE_MS = 400;
|
|
|
20
21
|
* where the user never pauses long enough for the debounce to fire.
|
|
21
22
|
*/
|
|
22
23
|
const SCROLL_INTERVAL_MS = 1200;
|
|
24
|
+
/**
|
|
25
|
+
* During scroll, throttle window-bounds re-measurement to at most once per
|
|
26
|
+
* this many ms. Bounds rarely change while scrolling, but we still want to
|
|
27
|
+
* catch orientation changes / keyboard / overlay animations.
|
|
28
|
+
*/
|
|
29
|
+
const REMEASURE_THROTTLE_MS = 500;
|
|
30
|
+
|
|
31
|
+
let scrollViewIdCounter = 0;
|
|
32
|
+
const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
|
|
23
33
|
|
|
24
34
|
/**
|
|
25
35
|
* Drop-in replacement for React Native's ScrollView that:
|
|
26
36
|
*
|
|
27
37
|
* 1. Reports scroll offset for accurate heatmap touch coords
|
|
28
38
|
* 2. Captures viewport tiles on scroll stops AND during continuous
|
|
29
|
-
* scroll for collaborative screen building
|
|
30
|
-
*
|
|
39
|
+
* scroll for collaborative screen building
|
|
40
|
+
* 3. Registers its window-relative bounds with the SDK so touches can
|
|
41
|
+
* be auto-classified as "content" (inside ScrollView) or "fixed"
|
|
42
|
+
* (outside, e.g. nav bars/headers)
|
|
31
43
|
*/
|
|
32
44
|
export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
33
45
|
({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
|
|
34
46
|
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
35
47
|
const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
48
|
+
const initialLayoutTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
36
49
|
const lastCaptureTime = useRef(0);
|
|
37
50
|
const lastCaptureY = useRef(-999);
|
|
51
|
+
const lastMeasureTime = useRef(0);
|
|
38
52
|
const initialCaptured = useRef(false);
|
|
39
53
|
const realContentHeight = useRef(0);
|
|
54
|
+
const scrollViewId = useRef<string>(nextScrollViewId());
|
|
55
|
+
const innerRef = useRef<ScrollView | null>(null);
|
|
56
|
+
const isMounted = useRef(true);
|
|
40
57
|
const scrollState = useRef<{
|
|
41
58
|
offsetY: number;
|
|
42
59
|
viewportHeight: number;
|
|
43
60
|
contentHeight: number;
|
|
44
61
|
} | null>(null);
|
|
45
62
|
|
|
46
|
-
//
|
|
63
|
+
// Forward the inner ref to the parent's ref (if provided).
|
|
64
|
+
// IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
|
|
65
|
+
// ref callback with null mid-update, and clearing would break async
|
|
66
|
+
// measurement callbacks that fire after the ref reattaches.
|
|
67
|
+
const setRef = useCallback((node: ScrollView | null) => {
|
|
68
|
+
if (node !== null) {
|
|
69
|
+
innerRef.current = node;
|
|
70
|
+
}
|
|
71
|
+
if (typeof ref === 'function') {
|
|
72
|
+
ref(node);
|
|
73
|
+
} else if (ref) {
|
|
74
|
+
(ref as React.MutableRefObject<ScrollView | null>).current = node;
|
|
75
|
+
}
|
|
76
|
+
}, [ref]);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Measure window-relative bounds and register with SDK.
|
|
80
|
+
* Idempotent + safe to call after unmount (guards via isMounted).
|
|
81
|
+
*/
|
|
82
|
+
const measureAndRegister = useCallback(() => {
|
|
83
|
+
if (!isMounted.current) return;
|
|
84
|
+
const node = innerRef.current as any;
|
|
85
|
+
if (!node || typeof node.measureInWindow !== 'function') return;
|
|
86
|
+
node.measureInWindow((x: number, y: number, width: number, height: number) => {
|
|
87
|
+
if (!isMounted.current) return;
|
|
88
|
+
// RN sometimes returns 0/0 dims before the view is positioned —
|
|
89
|
+
// ignore those, the next onLayout/onScroll will re-measure.
|
|
90
|
+
if (width <= 0 || height <= 0) return;
|
|
91
|
+
try {
|
|
92
|
+
AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
|
|
93
|
+
x, y, width, height,
|
|
94
|
+
});
|
|
95
|
+
} catch {}
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// Cleanup all timers + unregister + listeners on unmount
|
|
47
100
|
useEffect(() => {
|
|
101
|
+
const id = scrollViewId.current;
|
|
102
|
+
isMounted.current = true;
|
|
103
|
+
|
|
104
|
+
// Re-measure when keyboard appears/disappears (causes layout shift)
|
|
105
|
+
const showSub = Keyboard.addListener('keyboardDidShow', measureAndRegister);
|
|
106
|
+
const hideSub = Keyboard.addListener('keyboardDidHide', measureAndRegister);
|
|
107
|
+
|
|
48
108
|
return () => {
|
|
109
|
+
isMounted.current = false;
|
|
49
110
|
if (debounceTimer.current) clearTimeout(debounceTimer.current);
|
|
50
111
|
if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
|
|
112
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
113
|
+
showSub.remove();
|
|
114
|
+
hideSub.remove();
|
|
115
|
+
try {
|
|
116
|
+
AppAnalytics.getInstance().unregisterScrollView(id);
|
|
117
|
+
} catch {}
|
|
51
118
|
};
|
|
52
|
-
}, []);
|
|
119
|
+
}, [measureAndRegister]);
|
|
53
120
|
|
|
54
121
|
/**
|
|
55
122
|
* Core tile capture logic. Reads from refs (stable across renders).
|
|
@@ -58,6 +125,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
58
125
|
* @param force Skip throttle check (used for interval captures)
|
|
59
126
|
*/
|
|
60
127
|
const doCapture = (force = false) => {
|
|
128
|
+
if (!isMounted.current) return;
|
|
61
129
|
const state = scrollState.current;
|
|
62
130
|
if (!state || state.viewportHeight <= 0) return;
|
|
63
131
|
|
|
@@ -102,11 +170,21 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
102
170
|
const { contentOffset, layoutMeasurement, contentSize } =
|
|
103
171
|
event.nativeEvent;
|
|
104
172
|
|
|
105
|
-
//
|
|
173
|
+
// Update SDK with current scroll offset (both legacy + new registry)
|
|
106
174
|
try {
|
|
107
|
-
AppAnalytics.getInstance()
|
|
175
|
+
const sdk = AppAnalytics.getInstance();
|
|
176
|
+
sdk.setScrollOffset(contentOffset.y);
|
|
177
|
+
sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
|
|
108
178
|
} catch {}
|
|
109
179
|
|
|
180
|
+
// Re-measure bounds occasionally during scroll — catches layout
|
|
181
|
+
// shifts from animations, keyboard, orientation changes, etc.
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
|
|
184
|
+
lastMeasureTime.current = now;
|
|
185
|
+
measureAndRegister();
|
|
186
|
+
}
|
|
187
|
+
|
|
110
188
|
// Update scroll state — use realContentHeight if available
|
|
111
189
|
const bestContentHeight = realContentHeight.current > 0
|
|
112
190
|
? realContentHeight.current
|
|
@@ -126,12 +204,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
126
204
|
|
|
127
205
|
// Capture initial tile (Y~0) on first scroll event with real dims
|
|
128
206
|
if (!initialCaptured.current && contentOffset.y < 10) {
|
|
129
|
-
|
|
207
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
208
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
|
|
130
209
|
}
|
|
131
210
|
|
|
132
211
|
onScroll?.(event);
|
|
133
212
|
},
|
|
134
|
-
[onScroll],
|
|
213
|
+
[onScroll, measureAndRegister],
|
|
135
214
|
);
|
|
136
215
|
|
|
137
216
|
const handleScrollEndDrag = useCallback(
|
|
@@ -159,7 +238,6 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
159
238
|
|
|
160
239
|
const handleLayout = useCallback(
|
|
161
240
|
(event: LayoutChangeEvent) => {
|
|
162
|
-
// Capture initial tile after layout (gets real viewportHeight)
|
|
163
241
|
const { height } = event.nativeEvent.layout;
|
|
164
242
|
if (!initialCaptured.current && height > 0) {
|
|
165
243
|
scrollState.current = {
|
|
@@ -167,11 +245,17 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
167
245
|
viewportHeight: height,
|
|
168
246
|
contentHeight: realContentHeight.current || height,
|
|
169
247
|
};
|
|
170
|
-
|
|
248
|
+
// Clear any prior pending initial-capture timer before scheduling
|
|
249
|
+
if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
|
|
250
|
+
initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
|
|
171
251
|
}
|
|
252
|
+
// Register window-relative bounds immediately (no setTimeout).
|
|
253
|
+
// measureInWindow itself is async; if it returns 0/0 dims, the
|
|
254
|
+
// next onLayout / onScroll will retry — no race window.
|
|
255
|
+
measureAndRegister();
|
|
172
256
|
onLayout?.(event);
|
|
173
257
|
},
|
|
174
|
-
[onLayout],
|
|
258
|
+
[onLayout, measureAndRegister],
|
|
175
259
|
);
|
|
176
260
|
|
|
177
261
|
/**
|
|
@@ -193,7 +277,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
|
|
|
193
277
|
|
|
194
278
|
return (
|
|
195
279
|
<ScrollView
|
|
196
|
-
ref={
|
|
280
|
+
ref={setRef}
|
|
197
281
|
{...props}
|
|
198
282
|
onScroll={handleScroll}
|
|
199
283
|
onScrollEndDrag={handleScrollEndDrag}
|
package/src/core/client.ts
CHANGED
|
@@ -31,6 +31,25 @@ export class AppAnalytics {
|
|
|
31
31
|
private logger: Logger;
|
|
32
32
|
private appStateSubscription: { remove: () => void } | null = null;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Registry of active ScrollViews on the current screen.
|
|
36
|
+
* Each RepliqoScrollView registers its window-relative bounds and current
|
|
37
|
+
* scroll state. Used by trackTouch to auto-detect:
|
|
38
|
+
* - If touch is INSIDE a ScrollView → adjust Y by that ScrollView's
|
|
39
|
+
* scrollOffset, mark as content touch
|
|
40
|
+
* - If touch is OUTSIDE all ScrollViews → mark as fixed UI (no adjust)
|
|
41
|
+
*
|
|
42
|
+
* This makes heatmaps render fixed elements (nav bars, headers) at their
|
|
43
|
+
* viewport position, while still mapping scrollable content correctly.
|
|
44
|
+
*/
|
|
45
|
+
private scrollViewRegistry = new Map<
|
|
46
|
+
string,
|
|
47
|
+
{
|
|
48
|
+
bounds: { x: number; y: number; width: number; height: number };
|
|
49
|
+
scrollOffsetY: number;
|
|
50
|
+
}
|
|
51
|
+
>();
|
|
52
|
+
|
|
34
53
|
private constructor(config: SDKConfig) {
|
|
35
54
|
this.config = resolveConfig(config);
|
|
36
55
|
this.logger = new Logger(this.config.debug);
|
|
@@ -231,6 +250,20 @@ export class AppAnalytics {
|
|
|
231
250
|
}
|
|
232
251
|
}
|
|
233
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Track a touch event for heatmap analysis.
|
|
255
|
+
*
|
|
256
|
+
* @param x Touch X coordinate, in WINDOW coordinates (must match the
|
|
257
|
+
* coordinate system of `measureInWindow` — typically `pageX`
|
|
258
|
+
* from a React Native gesture event).
|
|
259
|
+
* @param y Touch Y coordinate, in WINDOW coordinates (must match the
|
|
260
|
+
* coordinate system of `measureInWindow` — typically `pageY`
|
|
261
|
+
* from a React Native gesture event). On Android with edge-to-edge
|
|
262
|
+
* enabled, ensure `pageY` excludes the status bar to stay
|
|
263
|
+
* consistent with `measureInWindow`.
|
|
264
|
+
* @param screenName Optional screen name (defaults to current screen).
|
|
265
|
+
* @param extra Optional extra data to attach to the event.
|
|
266
|
+
*/
|
|
234
267
|
trackTouch(
|
|
235
268
|
x: number,
|
|
236
269
|
y: number,
|
|
@@ -246,15 +279,65 @@ export class AppAnalytics {
|
|
|
246
279
|
return;
|
|
247
280
|
}
|
|
248
281
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
282
|
+
// Auto-detect: find which (if any) registered ScrollView contains
|
|
283
|
+
// this touch. If multiple match (nested ScrollViews, e.g. FlatList
|
|
284
|
+
// inside ScrollView), pick the SMALLEST by area — the most-nested
|
|
285
|
+
// one is the actual scroll context for that touch.
|
|
286
|
+
let bestMatchOffset = 0;
|
|
287
|
+
let bestMatchArea = Infinity;
|
|
288
|
+
let foundMatch = false;
|
|
289
|
+
|
|
290
|
+
for (const info of this.scrollViewRegistry.values()) {
|
|
291
|
+
const { bounds } = info;
|
|
292
|
+
if (
|
|
293
|
+
x >= bounds.x &&
|
|
294
|
+
x <= bounds.x + bounds.width &&
|
|
295
|
+
y >= bounds.y &&
|
|
296
|
+
y <= bounds.y + bounds.height
|
|
297
|
+
) {
|
|
298
|
+
const area = bounds.width * bounds.height;
|
|
299
|
+
if (area < bestMatchArea) {
|
|
300
|
+
bestMatchArea = area;
|
|
301
|
+
bestMatchOffset = info.scrollOffsetY;
|
|
302
|
+
foundMatch = true;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let activeScrollOffset: number;
|
|
308
|
+
let isFixed: boolean;
|
|
309
|
+
|
|
310
|
+
if (foundMatch) {
|
|
311
|
+
// Touch is inside a registered ScrollView → content touch
|
|
312
|
+
activeScrollOffset = bestMatchOffset;
|
|
313
|
+
isFixed = false;
|
|
314
|
+
} else if (this.scrollViewRegistry.size > 0) {
|
|
315
|
+
// ScrollViews ARE registered, but this touch is outside all of them
|
|
316
|
+
// → it's on fixed UI (nav bar, header, etc.)
|
|
317
|
+
activeScrollOffset = 0;
|
|
318
|
+
isFixed = true;
|
|
319
|
+
} else {
|
|
320
|
+
// No ScrollViews registered at all. Two possible cases:
|
|
321
|
+
// (a) The screen has no scrollable content → touch is fixed
|
|
322
|
+
// (b) RepliqoScrollView hasn't measured yet (first ~1 frame)
|
|
323
|
+
// Either way, marking as fixed is safer than guessing content
|
|
324
|
+
// position with stale scrollOffsetY from a previous screen.
|
|
325
|
+
activeScrollOffset = 0;
|
|
326
|
+
isFixed = true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const adjustedY = y + activeScrollOffset;
|
|
253
330
|
|
|
254
331
|
const event: AnalyticsEvent = {
|
|
255
332
|
type: 'touch',
|
|
256
333
|
screenName: screenName || this.currentScreen || undefined,
|
|
257
|
-
data: {
|
|
334
|
+
data: {
|
|
335
|
+
x,
|
|
336
|
+
y: adjustedY,
|
|
337
|
+
scrollOffsetY: activeScrollOffset,
|
|
338
|
+
isFixed,
|
|
339
|
+
...extra,
|
|
340
|
+
},
|
|
258
341
|
timestamp: new Date().toISOString(),
|
|
259
342
|
};
|
|
260
343
|
|
|
@@ -316,11 +399,49 @@ export class AppAnalytics {
|
|
|
316
399
|
* or manually from the host app's ScrollView onScroll handler.
|
|
317
400
|
* The offset is added to touch Y coordinates for accurate heatmaps
|
|
318
401
|
* on scrollable screens.
|
|
402
|
+
*
|
|
403
|
+
* @deprecated Prefer registerScrollView + updateScrollViewOffset for
|
|
404
|
+
* auto-detection of fixed vs scrollable touches. Kept for backward compat.
|
|
319
405
|
*/
|
|
320
406
|
setScrollOffset(y: number): void {
|
|
321
407
|
this.scrollOffsetY = y;
|
|
322
408
|
}
|
|
323
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Register a ScrollView's window-relative bounds with the SDK.
|
|
412
|
+
* Called by RepliqoScrollView on layout. Used to auto-detect whether
|
|
413
|
+
* a touch is on scrollable content or on fixed UI (nav bars, headers).
|
|
414
|
+
*/
|
|
415
|
+
registerScrollView(
|
|
416
|
+
id: string,
|
|
417
|
+
bounds: { x: number; y: number; width: number; height: number },
|
|
418
|
+
): void {
|
|
419
|
+
const existing = this.scrollViewRegistry.get(id);
|
|
420
|
+
this.scrollViewRegistry.set(id, {
|
|
421
|
+
bounds,
|
|
422
|
+
scrollOffsetY: existing?.scrollOffsetY ?? 0,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Update the scroll offset for a previously-registered ScrollView.
|
|
428
|
+
* Called by RepliqoScrollView on every scroll event.
|
|
429
|
+
*/
|
|
430
|
+
updateScrollViewOffset(id: string, scrollOffsetY: number): void {
|
|
431
|
+
const existing = this.scrollViewRegistry.get(id);
|
|
432
|
+
if (existing) {
|
|
433
|
+
existing.scrollOffsetY = scrollOffsetY;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Remove a ScrollView from the registry. Called by RepliqoScrollView
|
|
439
|
+
* on unmount.
|
|
440
|
+
*/
|
|
441
|
+
unregisterScrollView(id: string): void {
|
|
442
|
+
this.scrollViewRegistry.delete(id);
|
|
443
|
+
}
|
|
444
|
+
|
|
324
445
|
/**
|
|
325
446
|
* Capture the current viewport as a tile for collaborative screen
|
|
326
447
|
* building. Called by RepliqoScrollView on scroll stops.
|
|
@@ -414,6 +535,8 @@ export class AppAnalytics {
|
|
|
414
535
|
this.currentScreen = screenName;
|
|
415
536
|
this.currentScreenEnteredAt = new Date().toISOString();
|
|
416
537
|
this.scrollOffsetY = 0;
|
|
538
|
+
// Clear the ScrollView registry — old screen's scrollviews are gone
|
|
539
|
+
this.scrollViewRegistry.clear();
|
|
417
540
|
this.errorTracker?.setCurrentScreen(screenName);
|
|
418
541
|
this.snapshotCapture?.setCurrentScreen(screenName);
|
|
419
542
|
// NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
|