@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 — the backend composites
9
- * these into a full-content image over time
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 — the backend composites
58
- * these into a full-content image over time
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
- // Cleanup timers on unmount
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
- // Report scroll offset for touch coords
190
+ // Update SDK with current scroll offset (both legacy + new registry)
120
191
  try {
121
- client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
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
- setTimeout(() => doCapture(), 200);
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
- setTimeout(() => doCapture(), 1000);
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={ref} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
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';
@@ -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.
@@ -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
- // Adjust Y by the current scroll offset so touches map to content
167
- // position, not just viewport position. This makes heatmaps accurate
168
- // for scrollable screens.
169
- const adjustedY = y + this.scrollOffsetY;
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: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
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.4",
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 — the backend composites
30
- * these into a full-content image over time
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
- // Cleanup timers on unmount
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
- // Report scroll offset for touch coords
173
+ // Update SDK with current scroll offset (both legacy + new registry)
106
174
  try {
107
- AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
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
- setTimeout(() => doCapture(), 200);
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
- setTimeout(() => doCapture(), 1000);
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={ref}
280
+ ref={setRef}
197
281
  {...props}
198
282
  onScroll={handleScroll}
199
283
  onScrollEndDrag={handleScrollEndDrag}
@@ -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
- // Adjust Y by the current scroll offset so touches map to content
250
- // position, not just viewport position. This makes heatmaps accurate
251
- // for scrollable screens.
252
- const adjustedY = y + this.scrollOffsetY;
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: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
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.