@repliqo/sdk-react-native 0.3.4 → 0.3.5

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,13 +49,17 @@ 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
+ let scrollViewIdCounter = 0;
53
+ const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
52
54
  /**
53
55
  * Drop-in replacement for React Native's ScrollView that:
54
56
  *
55
57
  * 1. Reports scroll offset for accurate heatmap touch coords
56
58
  * 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
59
+ * scroll for collaborative screen building
60
+ * 3. Registers its window-relative bounds with the SDK so touches can
61
+ * be auto-classified as "content" (inside ScrollView) or "fixed"
62
+ * (outside, e.g. nav bars/headers)
59
63
  */
60
64
  exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
61
65
  const debounceTimer = (0, react_1.useRef)(null);
@@ -64,14 +68,47 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
64
68
  const lastCaptureY = (0, react_1.useRef)(-999);
65
69
  const initialCaptured = (0, react_1.useRef)(false);
66
70
  const realContentHeight = (0, react_1.useRef)(0);
71
+ const scrollViewId = (0, react_1.useRef)(nextScrollViewId());
72
+ const innerRef = (0, react_1.useRef)(null);
67
73
  const scrollState = (0, react_1.useRef)(null);
68
- // Cleanup timers on unmount
74
+ // Forward the inner ref to the parent's ref (if provided)
75
+ const setRef = (0, react_1.useCallback)((node) => {
76
+ innerRef.current = node;
77
+ if (typeof ref === 'function') {
78
+ ref(node);
79
+ }
80
+ else if (ref) {
81
+ ref.current = node;
82
+ }
83
+ }, [ref]);
84
+ /** Measure window-relative bounds and register with SDK. */
85
+ const measureAndRegister = (0, react_1.useCallback)(() => {
86
+ const node = innerRef.current;
87
+ if (!node || typeof node.measureInWindow !== 'function')
88
+ return;
89
+ node.measureInWindow((x, y, width, height) => {
90
+ if (width <= 0 || height <= 0)
91
+ return;
92
+ try {
93
+ client_1.AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
94
+ x, y, width, height,
95
+ });
96
+ }
97
+ catch { }
98
+ });
99
+ }, []);
100
+ // Cleanup timers + unregister on unmount
69
101
  (0, react_1.useEffect)(() => {
102
+ const id = scrollViewId.current;
70
103
  return () => {
71
104
  if (debounceTimer.current)
72
105
  clearTimeout(debounceTimer.current);
73
106
  if (scrollIntervalTimer.current)
74
107
  clearInterval(scrollIntervalTimer.current);
108
+ try {
109
+ client_1.AppAnalytics.getInstance().unregisterScrollView(id);
110
+ }
111
+ catch { }
75
112
  };
76
113
  }, []);
77
114
  /**
@@ -116,9 +153,11 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
116
153
  };
117
154
  const handleScroll = (0, react_1.useCallback)((event) => {
118
155
  const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
119
- // Report scroll offset for touch coords
156
+ // Update SDK with current scroll offset (both legacy + new registry)
120
157
  try {
121
- client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
158
+ const sdk = client_1.AppAnalytics.getInstance();
159
+ sdk.setScrollOffset(contentOffset.y);
160
+ sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
122
161
  }
123
162
  catch { }
124
163
  // Update scroll state — use realContentHeight if available
@@ -168,8 +207,12 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
168
207
  };
169
208
  setTimeout(() => doCapture(), 1000);
170
209
  }
210
+ // Register window-relative bounds with the SDK.
211
+ // Defer measurement to next tick — onLayout fires before the view
212
+ // is positioned in the window on some Android versions.
213
+ setTimeout(measureAndRegister, 50);
171
214
  onLayout?.(event);
172
- }, [onLayout]);
215
+ }, [onLayout, measureAndRegister]);
173
216
  /**
174
217
  * Track the real content height from onContentSizeChange.
175
218
  * This is more reliable than the first scroll event and ensures
@@ -183,6 +226,6 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
183
226
  }
184
227
  onContentSizeChange?.(w, h);
185
228
  }, [onContentSizeChange]);
186
- return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
229
+ return (<react_native_1.ScrollView ref={setRef} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
187
230
  });
188
231
  exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
@@ -14,6 +14,18 @@ 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;
@@ -29,8 +41,32 @@ export declare class AppAnalytics {
29
41
  * or manually from the host app's ScrollView onScroll handler.
30
42
  * The offset is added to touch Y coordinates for accurate heatmaps
31
43
  * on scrollable screens.
44
+ *
45
+ * @deprecated Prefer registerScrollView + updateScrollViewOffset for
46
+ * auto-detection of fixed vs scrollable touches. Kept for backward compat.
32
47
  */
33
48
  setScrollOffset(y: number): void;
49
+ /**
50
+ * Register a ScrollView's window-relative bounds with the SDK.
51
+ * Called by RepliqoScrollView on layout. Used to auto-detect whether
52
+ * a touch is on scrollable content or on fixed UI (nav bars, headers).
53
+ */
54
+ registerScrollView(id: string, bounds: {
55
+ x: number;
56
+ y: number;
57
+ width: number;
58
+ height: number;
59
+ }): void;
60
+ /**
61
+ * Update the scroll offset for a previously-registered ScrollView.
62
+ * Called by RepliqoScrollView on every scroll event.
63
+ */
64
+ updateScrollViewOffset(id: string, scrollOffsetY: number): void;
65
+ /**
66
+ * Remove a ScrollView from the registry. Called by RepliqoScrollView
67
+ * on unmount.
68
+ */
69
+ unregisterScrollView(id: string): void;
34
70
  /**
35
71
  * Capture the current viewport as a tile for collaborative screen
36
72
  * 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);
@@ -163,14 +175,41 @@ class AppAnalytics {
163
175
  if (!this.config.enableTouchTracking) {
164
176
  return;
165
177
  }
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;
178
+ // Auto-detect: find which (if any) registered ScrollView contains
179
+ // this touch. If inside, adjust Y by that ScrollView's scroll offset.
180
+ // If outside all ScrollViews, treat as fixed UI (no adjustment).
181
+ let activeScrollOffset = 0;
182
+ let isFixed = true;
183
+ if (this.scrollViewRegistry.size > 0) {
184
+ for (const info of this.scrollViewRegistry.values()) {
185
+ const { bounds } = info;
186
+ if (x >= bounds.x &&
187
+ x <= bounds.x + bounds.width &&
188
+ y >= bounds.y &&
189
+ y <= bounds.y + bounds.height) {
190
+ activeScrollOffset = info.scrollOffsetY;
191
+ isFixed = false;
192
+ break;
193
+ }
194
+ }
195
+ }
196
+ else {
197
+ // No ScrollViews registered → fall back to legacy behavior
198
+ // (use the manually-set scrollOffsetY, treat as content touch).
199
+ activeScrollOffset = this.scrollOffsetY;
200
+ isFixed = false;
201
+ }
202
+ const adjustedY = y + activeScrollOffset;
170
203
  const event = {
171
204
  type: 'touch',
172
205
  screenName: screenName || this.currentScreen || undefined,
173
- data: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
206
+ data: {
207
+ x,
208
+ y: adjustedY,
209
+ scrollOffsetY: activeScrollOffset,
210
+ isFixed,
211
+ ...extra,
212
+ },
174
213
  timestamp: new Date().toISOString(),
175
214
  };
176
215
  this.eventQueue.add(event);
@@ -219,10 +258,42 @@ class AppAnalytics {
219
258
  * or manually from the host app's ScrollView onScroll handler.
220
259
  * The offset is added to touch Y coordinates for accurate heatmaps
221
260
  * on scrollable screens.
261
+ *
262
+ * @deprecated Prefer registerScrollView + updateScrollViewOffset for
263
+ * auto-detection of fixed vs scrollable touches. Kept for backward compat.
222
264
  */
223
265
  setScrollOffset(y) {
224
266
  this.scrollOffsetY = y;
225
267
  }
268
+ /**
269
+ * Register a ScrollView's window-relative bounds with the SDK.
270
+ * Called by RepliqoScrollView on layout. Used to auto-detect whether
271
+ * a touch is on scrollable content or on fixed UI (nav bars, headers).
272
+ */
273
+ registerScrollView(id, bounds) {
274
+ const existing = this.scrollViewRegistry.get(id);
275
+ this.scrollViewRegistry.set(id, {
276
+ bounds,
277
+ scrollOffsetY: existing?.scrollOffsetY ?? 0,
278
+ });
279
+ }
280
+ /**
281
+ * Update the scroll offset for a previously-registered ScrollView.
282
+ * Called by RepliqoScrollView on every scroll event.
283
+ */
284
+ updateScrollViewOffset(id, scrollOffsetY) {
285
+ const existing = this.scrollViewRegistry.get(id);
286
+ if (existing) {
287
+ existing.scrollOffsetY = scrollOffsetY;
288
+ }
289
+ }
290
+ /**
291
+ * Remove a ScrollView from the registry. Called by RepliqoScrollView
292
+ * on unmount.
293
+ */
294
+ unregisterScrollView(id) {
295
+ this.scrollViewRegistry.delete(id);
296
+ }
226
297
  /**
227
298
  * Capture the current viewport as a tile for collaborative screen
228
299
  * building. Called by RepliqoScrollView on scroll stops.
@@ -282,6 +353,8 @@ class AppAnalytics {
282
353
  this.currentScreen = screenName;
283
354
  this.currentScreenEnteredAt = new Date().toISOString();
284
355
  this.scrollOffsetY = 0;
356
+ // Clear the ScrollView registry — old screen's scrollviews are gone
357
+ this.scrollViewRegistry.clear();
285
358
  this.errorTracker?.setCurrentScreen(screenName);
286
359
  this.snapshotCapture?.setCurrentScreen(screenName);
287
360
  // 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.5",
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",
@@ -21,13 +21,18 @@ const THROTTLE_MS = 400;
21
21
  */
22
22
  const SCROLL_INTERVAL_MS = 1200;
23
23
 
24
+ let scrollViewIdCounter = 0;
25
+ const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
26
+
24
27
  /**
25
28
  * Drop-in replacement for React Native's ScrollView that:
26
29
  *
27
30
  * 1. Reports scroll offset for accurate heatmap touch coords
28
31
  * 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
32
+ * scroll for collaborative screen building
33
+ * 3. Registers its window-relative bounds with the SDK so touches can
34
+ * be auto-classified as "content" (inside ScrollView) or "fixed"
35
+ * (outside, e.g. nav bars/headers)
31
36
  */
32
37
  export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
33
38
  ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
@@ -37,17 +42,47 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
37
42
  const lastCaptureY = useRef(-999);
38
43
  const initialCaptured = useRef(false);
39
44
  const realContentHeight = useRef(0);
45
+ const scrollViewId = useRef<string>(nextScrollViewId());
46
+ const innerRef = useRef<ScrollView | null>(null);
40
47
  const scrollState = useRef<{
41
48
  offsetY: number;
42
49
  viewportHeight: number;
43
50
  contentHeight: number;
44
51
  } | null>(null);
45
52
 
46
- // Cleanup timers on unmount
53
+ // Forward the inner ref to the parent's ref (if provided)
54
+ const setRef = useCallback((node: ScrollView | null) => {
55
+ innerRef.current = node;
56
+ if (typeof ref === 'function') {
57
+ ref(node);
58
+ } else if (ref) {
59
+ (ref as React.MutableRefObject<ScrollView | null>).current = node;
60
+ }
61
+ }, [ref]);
62
+
63
+ /** Measure window-relative bounds and register with SDK. */
64
+ const measureAndRegister = useCallback(() => {
65
+ const node = innerRef.current as any;
66
+ if (!node || typeof node.measureInWindow !== 'function') return;
67
+ node.measureInWindow((x: number, y: number, width: number, height: number) => {
68
+ if (width <= 0 || height <= 0) return;
69
+ try {
70
+ AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
71
+ x, y, width, height,
72
+ });
73
+ } catch {}
74
+ });
75
+ }, []);
76
+
77
+ // Cleanup timers + unregister on unmount
47
78
  useEffect(() => {
79
+ const id = scrollViewId.current;
48
80
  return () => {
49
81
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
50
82
  if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
83
+ try {
84
+ AppAnalytics.getInstance().unregisterScrollView(id);
85
+ } catch {}
51
86
  };
52
87
  }, []);
53
88
 
@@ -102,9 +137,11 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
102
137
  const { contentOffset, layoutMeasurement, contentSize } =
103
138
  event.nativeEvent;
104
139
 
105
- // Report scroll offset for touch coords
140
+ // Update SDK with current scroll offset (both legacy + new registry)
106
141
  try {
107
- AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
142
+ const sdk = AppAnalytics.getInstance();
143
+ sdk.setScrollOffset(contentOffset.y);
144
+ sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
108
145
  } catch {}
109
146
 
110
147
  // Update scroll state — use realContentHeight if available
@@ -169,9 +206,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
169
206
  };
170
207
  setTimeout(() => doCapture(), 1000);
171
208
  }
209
+ // Register window-relative bounds with the SDK.
210
+ // Defer measurement to next tick — onLayout fires before the view
211
+ // is positioned in the window on some Android versions.
212
+ setTimeout(measureAndRegister, 50);
172
213
  onLayout?.(event);
173
214
  },
174
- [onLayout],
215
+ [onLayout, measureAndRegister],
175
216
  );
176
217
 
177
218
  /**
@@ -193,7 +234,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
193
234
 
194
235
  return (
195
236
  <ScrollView
196
- ref={ref}
237
+ ref={setRef}
197
238
  {...props}
198
239
  onScroll={handleScroll}
199
240
  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);
@@ -246,15 +265,45 @@ export class AppAnalytics {
246
265
  return;
247
266
  }
248
267
 
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;
268
+ // Auto-detect: find which (if any) registered ScrollView contains
269
+ // this touch. If inside, adjust Y by that ScrollView's scroll offset.
270
+ // If outside all ScrollViews, treat as fixed UI (no adjustment).
271
+ let activeScrollOffset = 0;
272
+ let isFixed = true;
273
+
274
+ if (this.scrollViewRegistry.size > 0) {
275
+ for (const info of this.scrollViewRegistry.values()) {
276
+ const { bounds } = info;
277
+ if (
278
+ x >= bounds.x &&
279
+ x <= bounds.x + bounds.width &&
280
+ y >= bounds.y &&
281
+ y <= bounds.y + bounds.height
282
+ ) {
283
+ activeScrollOffset = info.scrollOffsetY;
284
+ isFixed = false;
285
+ break;
286
+ }
287
+ }
288
+ } else {
289
+ // No ScrollViews registered → fall back to legacy behavior
290
+ // (use the manually-set scrollOffsetY, treat as content touch).
291
+ activeScrollOffset = this.scrollOffsetY;
292
+ isFixed = false;
293
+ }
294
+
295
+ const adjustedY = y + activeScrollOffset;
253
296
 
254
297
  const event: AnalyticsEvent = {
255
298
  type: 'touch',
256
299
  screenName: screenName || this.currentScreen || undefined,
257
- data: { x, y: adjustedY, scrollOffsetY: this.scrollOffsetY, ...extra },
300
+ data: {
301
+ x,
302
+ y: adjustedY,
303
+ scrollOffsetY: activeScrollOffset,
304
+ isFixed,
305
+ ...extra,
306
+ },
258
307
  timestamp: new Date().toISOString(),
259
308
  };
260
309
 
@@ -316,11 +365,49 @@ export class AppAnalytics {
316
365
  * or manually from the host app's ScrollView onScroll handler.
317
366
  * The offset is added to touch Y coordinates for accurate heatmaps
318
367
  * on scrollable screens.
368
+ *
369
+ * @deprecated Prefer registerScrollView + updateScrollViewOffset for
370
+ * auto-detection of fixed vs scrollable touches. Kept for backward compat.
319
371
  */
320
372
  setScrollOffset(y: number): void {
321
373
  this.scrollOffsetY = y;
322
374
  }
323
375
 
376
+ /**
377
+ * Register a ScrollView's window-relative bounds with the SDK.
378
+ * Called by RepliqoScrollView on layout. Used to auto-detect whether
379
+ * a touch is on scrollable content or on fixed UI (nav bars, headers).
380
+ */
381
+ registerScrollView(
382
+ id: string,
383
+ bounds: { x: number; y: number; width: number; height: number },
384
+ ): void {
385
+ const existing = this.scrollViewRegistry.get(id);
386
+ this.scrollViewRegistry.set(id, {
387
+ bounds,
388
+ scrollOffsetY: existing?.scrollOffsetY ?? 0,
389
+ });
390
+ }
391
+
392
+ /**
393
+ * Update the scroll offset for a previously-registered ScrollView.
394
+ * Called by RepliqoScrollView on every scroll event.
395
+ */
396
+ updateScrollViewOffset(id: string, scrollOffsetY: number): void {
397
+ const existing = this.scrollViewRegistry.get(id);
398
+ if (existing) {
399
+ existing.scrollOffsetY = scrollOffsetY;
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Remove a ScrollView from the registry. Called by RepliqoScrollView
405
+ * on unmount.
406
+ */
407
+ unregisterScrollView(id: string): void {
408
+ this.scrollViewRegistry.delete(id);
409
+ }
410
+
324
411
  /**
325
412
  * Capture the current viewport as a tile for collaborative screen
326
413
  * building. Called by RepliqoScrollView on scroll stops.
@@ -414,6 +501,8 @@ export class AppAnalytics {
414
501
  this.currentScreen = screenName;
415
502
  this.currentScreenEnteredAt = new Date().toISOString();
416
503
  this.scrollOffsetY = 0;
504
+ // Clear the ScrollView registry — old screen's scrollviews are gone
505
+ this.scrollViewRegistry.clear();
417
506
  this.errorTracker?.setCurrentScreen(screenName);
418
507
  this.snapshotCapture?.setCurrentScreen(screenName);
419
508
  // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.