@repliqo/sdk-react-native 0.3.5 → 0.3.7

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.
@@ -49,6 +49,12 @@ 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;
52
58
  let scrollViewIdCounter = 0;
53
59
  const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
54
60
  /**
@@ -64,16 +70,28 @@ const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
64
70
  exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
65
71
  const debounceTimer = (0, react_1.useRef)(null);
66
72
  const scrollIntervalTimer = (0, react_1.useRef)(null);
73
+ const initialLayoutTimer = (0, react_1.useRef)(null);
67
74
  const lastCaptureTime = (0, react_1.useRef)(0);
68
75
  const lastCaptureY = (0, react_1.useRef)(-999);
76
+ const lastMeasureTime = (0, react_1.useRef)(0);
69
77
  const initialCaptured = (0, react_1.useRef)(false);
70
78
  const realContentHeight = (0, react_1.useRef)(0);
71
79
  const scrollViewId = (0, react_1.useRef)(nextScrollViewId());
72
80
  const innerRef = (0, react_1.useRef)(null);
81
+ const isMounted = (0, react_1.useRef)(true);
82
+ // Last measured window-relative top Y of the ScrollView — passed to
83
+ // captureScrollTile so the backend can crop each tile to just the
84
+ // scroll viewport area (no fixed header/footer duplication).
85
+ const lastViewportTop = (0, react_1.useRef)(null);
73
86
  const scrollState = (0, react_1.useRef)(null);
74
- // Forward the inner ref to the parent's ref (if provided)
87
+ // Forward the inner ref to the parent's ref (if provided).
88
+ // IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
89
+ // ref callback with null mid-update, and clearing would break async
90
+ // measurement callbacks that fire after the ref reattaches.
75
91
  const setRef = (0, react_1.useCallback)((node) => {
76
- innerRef.current = node;
92
+ if (node !== null) {
93
+ innerRef.current = node;
94
+ }
77
95
  if (typeof ref === 'function') {
78
96
  ref(node);
79
97
  }
@@ -81,14 +99,24 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
81
99
  ref.current = node;
82
100
  }
83
101
  }, [ref]);
84
- /** Measure window-relative bounds and register with SDK. */
102
+ /**
103
+ * Measure window-relative bounds and register with SDK.
104
+ * Idempotent + safe to call after unmount (guards via isMounted).
105
+ */
85
106
  const measureAndRegister = (0, react_1.useCallback)(() => {
107
+ if (!isMounted.current)
108
+ return;
86
109
  const node = innerRef.current;
87
110
  if (!node || typeof node.measureInWindow !== 'function')
88
111
  return;
89
112
  node.measureInWindow((x, y, width, height) => {
113
+ if (!isMounted.current)
114
+ return;
115
+ // RN sometimes returns 0/0 dims before the view is positioned —
116
+ // ignore those, the next onLayout/onScroll will re-measure.
90
117
  if (width <= 0 || height <= 0)
91
118
  return;
119
+ lastViewportTop.current = y;
92
120
  try {
93
121
  client_1.AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
94
122
  x, y, width, height,
@@ -97,20 +125,29 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
97
125
  catch { }
98
126
  });
99
127
  }, []);
100
- // Cleanup timers + unregister on unmount
128
+ // Cleanup all timers + unregister + listeners on unmount
101
129
  (0, react_1.useEffect)(() => {
102
130
  const id = scrollViewId.current;
131
+ isMounted.current = true;
132
+ // Re-measure when keyboard appears/disappears (causes layout shift)
133
+ const showSub = react_native_1.Keyboard.addListener('keyboardDidShow', measureAndRegister);
134
+ const hideSub = react_native_1.Keyboard.addListener('keyboardDidHide', measureAndRegister);
103
135
  return () => {
136
+ isMounted.current = false;
104
137
  if (debounceTimer.current)
105
138
  clearTimeout(debounceTimer.current);
106
139
  if (scrollIntervalTimer.current)
107
140
  clearInterval(scrollIntervalTimer.current);
141
+ if (initialLayoutTimer.current)
142
+ clearTimeout(initialLayoutTimer.current);
143
+ showSub.remove();
144
+ hideSub.remove();
108
145
  try {
109
146
  client_1.AppAnalytics.getInstance().unregisterScrollView(id);
110
147
  }
111
148
  catch { }
112
149
  };
113
- }, []);
150
+ }, [measureAndRegister]);
114
151
  /**
115
152
  * Core tile capture logic. Reads from refs (stable across renders).
116
153
  * Checks throttle + delta before capturing.
@@ -118,6 +155,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
118
155
  * @param force Skip throttle check (used for interval captures)
119
156
  */
120
157
  const doCapture = (force = false) => {
158
+ if (!isMounted.current)
159
+ return;
121
160
  const state = scrollState.current;
122
161
  if (!state || state.viewportHeight <= 0)
123
162
  return;
@@ -132,7 +171,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
132
171
  lastCaptureY.current = state.offsetY;
133
172
  initialCaptured.current = true;
134
173
  try {
135
- client_1.AppAnalytics.getInstance().captureScrollTile(state.offsetY, state.viewportHeight, state.contentHeight);
174
+ const windowHeight = react_native_1.Dimensions.get('window').height;
175
+ client_1.AppAnalytics.getInstance().captureScrollTile(state.offsetY, state.viewportHeight, state.contentHeight, lastViewportTop.current ?? undefined, windowHeight);
136
176
  }
137
177
  catch { }
138
178
  };
@@ -160,6 +200,13 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
160
200
  sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
161
201
  }
162
202
  catch { }
203
+ // Re-measure bounds occasionally during scroll — catches layout
204
+ // shifts from animations, keyboard, orientation changes, etc.
205
+ const now = Date.now();
206
+ if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
207
+ lastMeasureTime.current = now;
208
+ measureAndRegister();
209
+ }
163
210
  // Update scroll state — use realContentHeight if available
164
211
  const bestContentHeight = realContentHeight.current > 0
165
212
  ? realContentHeight.current
@@ -177,10 +224,12 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
177
224
  startScrollInterval();
178
225
  // Capture initial tile (Y~0) on first scroll event with real dims
179
226
  if (!initialCaptured.current && contentOffset.y < 10) {
180
- setTimeout(() => doCapture(), 200);
227
+ if (initialLayoutTimer.current)
228
+ clearTimeout(initialLayoutTimer.current);
229
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
181
230
  }
182
231
  onScroll?.(event);
183
- }, [onScroll]);
232
+ }, [onScroll, measureAndRegister]);
184
233
  const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
185
234
  // User lifted finger — capture immediately
186
235
  stopScrollInterval();
@@ -197,7 +246,6 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
197
246
  onMomentumScrollEnd?.(event);
198
247
  }, [onMomentumScrollEnd]);
199
248
  const handleLayout = (0, react_1.useCallback)((event) => {
200
- // Capture initial tile after layout (gets real viewportHeight)
201
249
  const { height } = event.nativeEvent.layout;
202
250
  if (!initialCaptured.current && height > 0) {
203
251
  scrollState.current = {
@@ -205,12 +253,15 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
205
253
  viewportHeight: height,
206
254
  contentHeight: realContentHeight.current || height,
207
255
  };
208
- setTimeout(() => doCapture(), 1000);
256
+ // Clear any prior pending initial-capture timer before scheduling
257
+ if (initialLayoutTimer.current)
258
+ clearTimeout(initialLayoutTimer.current);
259
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
209
260
  }
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);
261
+ // Register window-relative bounds immediately (no setTimeout).
262
+ // measureInWindow itself is async; if it returns 0/0 dims, the
263
+ // next onLayout / onScroll will retry no race window.
264
+ measureAndRegister();
214
265
  onLayout?.(event);
215
266
  }, [onLayout, measureAndRegister]);
216
267
  /**
@@ -32,6 +32,20 @@ export declare class AppAnalytics {
32
32
  static destroy(): void;
33
33
  startSession(deviceId: string, deviceInfo?: DeviceInfo): Promise<string>;
34
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
+ */
35
49
  trackTouch(x: number, y: number, screenName?: string, extra?: Record<string, any>): void;
36
50
  trackNavigation(fromScreen: string, toScreen: string): void;
37
51
  trackCustomEvent(eventName: string, data?: Record<string, any>): void;
@@ -71,11 +85,16 @@ export declare class AppAnalytics {
71
85
  * Capture the current viewport as a tile for collaborative screen
72
86
  * building. Called by RepliqoScrollView on scroll stops.
73
87
  *
74
- * The tile includes the scrollOffsetY so the backend knows where
75
- * in the full content this viewport belongs. Multiple users/sessions
76
- * contribute tiles until the full content is covered.
88
+ * The tile includes:
89
+ * - `scrollOffsetY`: where in the full content this viewport belongs
90
+ * - `viewportTop`: Y position (window logical px) of the ScrollView's
91
+ * top edge. Used by the backend to crop each tile to just the
92
+ * scrollable area, removing fixed headers/footers that would
93
+ * otherwise duplicate in the composite.
94
+ * - `windowHeight`: window height in logical px, for image-to-logical
95
+ * scale computation during backend cropping.
77
96
  */
78
- captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number): void;
97
+ captureScrollTile(scrollOffsetY: number, viewportHeight: number, contentHeight: number, viewportTop?: number, windowHeight?: number): void;
79
98
  /**
80
99
  * Programmatically scroll-scan the current ScrollView and upload all
81
100
  * tiles to the backend. Invisible to the user (~300-500ms).
@@ -167,6 +167,20 @@ class AppAnalytics {
167
167
  this.snapshotCapture.setSessionId(null);
168
168
  }
169
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
+ */
170
184
  trackTouch(x, y, screenName, extra) {
171
185
  if (!this.sessionId) {
172
186
  this.logger.warn('Cannot track touch: no active session');
@@ -176,29 +190,48 @@ class AppAnalytics {
176
190
  return;
177
191
  }
178
192
  // 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
+ // 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;
193
210
  }
194
211
  }
195
212
  }
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;
213
+ let activeScrollOffset;
214
+ let isFixed;
215
+ if (foundMatch) {
216
+ // Touch is inside a registered ScrollView → content touch
217
+ activeScrollOffset = bestMatchOffset;
200
218
  isFixed = false;
201
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
+ }
202
235
  const adjustedY = y + activeScrollOffset;
203
236
  const event = {
204
237
  type: 'touch',
@@ -298,11 +331,16 @@ class AppAnalytics {
298
331
  * Capture the current viewport as a tile for collaborative screen
299
332
  * building. Called by RepliqoScrollView on scroll stops.
300
333
  *
301
- * The tile includes the scrollOffsetY so the backend knows where
302
- * in the full content this viewport belongs. Multiple users/sessions
303
- * contribute tiles until the full content is covered.
334
+ * The tile includes:
335
+ * - `scrollOffsetY`: where in the full content this viewport belongs
336
+ * - `viewportTop`: Y position (window logical px) of the ScrollView's
337
+ * top edge. Used by the backend to crop each tile to just the
338
+ * scrollable area, removing fixed headers/footers that would
339
+ * otherwise duplicate in the composite.
340
+ * - `windowHeight`: window height in logical px, for image-to-logical
341
+ * scale computation during backend cropping.
304
342
  */
305
- captureScrollTile(scrollOffsetY, viewportHeight, contentHeight) {
343
+ captureScrollTile(scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight) {
306
344
  if (!this.sessionId || !this.currentScreen)
307
345
  return;
308
346
  if (viewportHeight <= 0)
@@ -317,8 +355,9 @@ class AppAnalytics {
317
355
  return;
318
356
  this.logger.log(`Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
319
357
  `vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
358
+ `top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
320
359
  `${Math.round(frame.image.length / 1024)}KB`);
321
- await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight);
360
+ await this.apiClient.uploadScreenTile(sessionId, screenName, frame.image, frame.width, frame.height, scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight);
322
361
  }
323
362
  catch (err) {
324
363
  this.logger.warn('Scroll tile failed:', err);
@@ -32,7 +32,14 @@ export declare class ApiClient {
32
32
  */
33
33
  /**
34
34
  * Upload a scroll tile for collaborative screen building.
35
+ *
36
+ * @param viewportTop Y position (window logical px) of the ScrollView's
37
+ * top edge. Used by the backend to crop each tile to just the
38
+ * scrollable area, removing fixed headers/footers that would
39
+ * otherwise duplicate in the composite.
40
+ * @param windowHeight Window height (logical px) at capture time.
41
+ * Used by the backend to compute image-to-logical scale for cropping.
35
42
  */
36
- uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number): Promise<void>;
43
+ uploadScreenTile(sessionId: string, screenName: string, image: string, width: number, height: number, scrollOffsetY: number, viewportHeight: number, contentHeight: number, viewportTop?: number, windowHeight?: number): Promise<void>;
37
44
  uploadScreenCapture(screenName: string, image: string, width: number, height: number, isFullContent: boolean): Promise<void>;
38
45
  }
@@ -158,22 +158,36 @@ class ApiClient {
158
158
  */
159
159
  /**
160
160
  * Upload a scroll tile for collaborative screen building.
161
+ *
162
+ * @param viewportTop Y position (window logical px) of the ScrollView's
163
+ * top edge. Used by the backend to crop each tile to just the
164
+ * scrollable area, removing fixed headers/footers that would
165
+ * otherwise duplicate in the composite.
166
+ * @param windowHeight Window height (logical px) at capture time.
167
+ * Used by the backend to compute image-to-logical scale for cropping.
161
168
  */
162
- async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight) {
169
+ async uploadScreenTile(sessionId, screenName, image, width, height, scrollOffsetY, viewportHeight, contentHeight, viewportTop, windowHeight) {
163
170
  try {
171
+ const body = {
172
+ sessionId,
173
+ screenName,
174
+ image,
175
+ width,
176
+ height,
177
+ scrollOffsetY: Math.round(scrollOffsetY),
178
+ viewportHeight: Math.round(viewportHeight),
179
+ contentHeight: Math.round(contentHeight),
180
+ };
181
+ if (typeof viewportTop === 'number' && Number.isFinite(viewportTop)) {
182
+ body.viewportTop = Math.round(viewportTop);
183
+ }
184
+ if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
185
+ body.windowHeight = Math.round(windowHeight);
186
+ }
164
187
  const response = await fetch(`${this.baseUrl}/api/screen-tiles`, {
165
188
  method: 'POST',
166
189
  headers: this.getHeaders(),
167
- body: JSON.stringify({
168
- sessionId,
169
- screenName,
170
- image,
171
- width,
172
- height,
173
- scrollOffsetY: Math.round(scrollOffsetY),
174
- viewportHeight: Math.round(viewportHeight),
175
- contentHeight: Math.round(contentHeight),
176
- }),
190
+ body: JSON.stringify(body),
177
191
  });
178
192
  if (!response.ok) {
179
193
  const text = await response.text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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,8 @@
1
1
  import React, { useCallback, useRef, useEffect } from 'react';
2
2
  import {
3
3
  ScrollView,
4
+ Keyboard,
5
+ Dimensions,
4
6
  type ScrollViewProps,
5
7
  type NativeSyntheticEvent,
6
8
  type NativeScrollEvent,
@@ -20,6 +22,12 @@ const THROTTLE_MS = 400;
20
22
  * where the user never pauses long enough for the debounce to fire.
21
23
  */
22
24
  const SCROLL_INTERVAL_MS = 1200;
25
+ /**
26
+ * During scroll, throttle window-bounds re-measurement to at most once per
27
+ * this many ms. Bounds rarely change while scrolling, but we still want to
28
+ * catch orientation changes / keyboard / overlay animations.
29
+ */
30
+ const REMEASURE_THROTTLE_MS = 500;
23
31
 
24
32
  let scrollViewIdCounter = 0;
25
33
  const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
@@ -38,21 +46,33 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
38
46
  ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
39
47
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
40
48
  const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
49
+ const initialLayoutTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
41
50
  const lastCaptureTime = useRef(0);
42
51
  const lastCaptureY = useRef(-999);
52
+ const lastMeasureTime = useRef(0);
43
53
  const initialCaptured = useRef(false);
44
54
  const realContentHeight = useRef(0);
45
55
  const scrollViewId = useRef<string>(nextScrollViewId());
46
56
  const innerRef = useRef<ScrollView | null>(null);
57
+ const isMounted = useRef(true);
58
+ // Last measured window-relative top Y of the ScrollView — passed to
59
+ // captureScrollTile so the backend can crop each tile to just the
60
+ // scroll viewport area (no fixed header/footer duplication).
61
+ const lastViewportTop = useRef<number | null>(null);
47
62
  const scrollState = useRef<{
48
63
  offsetY: number;
49
64
  viewportHeight: number;
50
65
  contentHeight: number;
51
66
  } | null>(null);
52
67
 
53
- // Forward the inner ref to the parent's ref (if provided)
68
+ // Forward the inner ref to the parent's ref (if provided).
69
+ // IMPORTANT: do NOT clear innerRef on cleanup — RN sometimes calls the
70
+ // ref callback with null mid-update, and clearing would break async
71
+ // measurement callbacks that fire after the ref reattaches.
54
72
  const setRef = useCallback((node: ScrollView | null) => {
55
- innerRef.current = node;
73
+ if (node !== null) {
74
+ innerRef.current = node;
75
+ }
56
76
  if (typeof ref === 'function') {
57
77
  ref(node);
58
78
  } else if (ref) {
@@ -60,12 +80,20 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
60
80
  }
61
81
  }, [ref]);
62
82
 
63
- /** Measure window-relative bounds and register with SDK. */
83
+ /**
84
+ * Measure window-relative bounds and register with SDK.
85
+ * Idempotent + safe to call after unmount (guards via isMounted).
86
+ */
64
87
  const measureAndRegister = useCallback(() => {
88
+ if (!isMounted.current) return;
65
89
  const node = innerRef.current as any;
66
90
  if (!node || typeof node.measureInWindow !== 'function') return;
67
91
  node.measureInWindow((x: number, y: number, width: number, height: number) => {
92
+ if (!isMounted.current) return;
93
+ // RN sometimes returns 0/0 dims before the view is positioned —
94
+ // ignore those, the next onLayout/onScroll will re-measure.
68
95
  if (width <= 0 || height <= 0) return;
96
+ lastViewportTop.current = y;
69
97
  try {
70
98
  AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
71
99
  x, y, width, height,
@@ -74,17 +102,27 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
74
102
  });
75
103
  }, []);
76
104
 
77
- // Cleanup timers + unregister on unmount
105
+ // Cleanup all timers + unregister + listeners on unmount
78
106
  useEffect(() => {
79
107
  const id = scrollViewId.current;
108
+ isMounted.current = true;
109
+
110
+ // Re-measure when keyboard appears/disappears (causes layout shift)
111
+ const showSub = Keyboard.addListener('keyboardDidShow', measureAndRegister);
112
+ const hideSub = Keyboard.addListener('keyboardDidHide', measureAndRegister);
113
+
80
114
  return () => {
115
+ isMounted.current = false;
81
116
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
82
117
  if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
118
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
119
+ showSub.remove();
120
+ hideSub.remove();
83
121
  try {
84
122
  AppAnalytics.getInstance().unregisterScrollView(id);
85
123
  } catch {}
86
124
  };
87
- }, []);
125
+ }, [measureAndRegister]);
88
126
 
89
127
  /**
90
128
  * Core tile capture logic. Reads from refs (stable across renders).
@@ -93,6 +131,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
93
131
  * @param force Skip throttle check (used for interval captures)
94
132
  */
95
133
  const doCapture = (force = false) => {
134
+ if (!isMounted.current) return;
96
135
  const state = scrollState.current;
97
136
  if (!state || state.viewportHeight <= 0) return;
98
137
 
@@ -108,10 +147,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
108
147
  initialCaptured.current = true;
109
148
 
110
149
  try {
150
+ const windowHeight = Dimensions.get('window').height;
111
151
  AppAnalytics.getInstance().captureScrollTile(
112
152
  state.offsetY,
113
153
  state.viewportHeight,
114
154
  state.contentHeight,
155
+ lastViewportTop.current ?? undefined,
156
+ windowHeight,
115
157
  );
116
158
  } catch {}
117
159
  };
@@ -144,6 +186,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
144
186
  sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
145
187
  } catch {}
146
188
 
189
+ // Re-measure bounds occasionally during scroll — catches layout
190
+ // shifts from animations, keyboard, orientation changes, etc.
191
+ const now = Date.now();
192
+ if (now - lastMeasureTime.current > REMEASURE_THROTTLE_MS) {
193
+ lastMeasureTime.current = now;
194
+ measureAndRegister();
195
+ }
196
+
147
197
  // Update scroll state — use realContentHeight if available
148
198
  const bestContentHeight = realContentHeight.current > 0
149
199
  ? realContentHeight.current
@@ -163,12 +213,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
163
213
 
164
214
  // Capture initial tile (Y~0) on first scroll event with real dims
165
215
  if (!initialCaptured.current && contentOffset.y < 10) {
166
- setTimeout(() => doCapture(), 200);
216
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
217
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
167
218
  }
168
219
 
169
220
  onScroll?.(event);
170
221
  },
171
- [onScroll],
222
+ [onScroll, measureAndRegister],
172
223
  );
173
224
 
174
225
  const handleScrollEndDrag = useCallback(
@@ -196,7 +247,6 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
196
247
 
197
248
  const handleLayout = useCallback(
198
249
  (event: LayoutChangeEvent) => {
199
- // Capture initial tile after layout (gets real viewportHeight)
200
250
  const { height } = event.nativeEvent.layout;
201
251
  if (!initialCaptured.current && height > 0) {
202
252
  scrollState.current = {
@@ -204,12 +254,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
204
254
  viewportHeight: height,
205
255
  contentHeight: realContentHeight.current || height,
206
256
  };
207
- setTimeout(() => doCapture(), 1000);
257
+ // Clear any prior pending initial-capture timer before scheduling
258
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
259
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 1000);
208
260
  }
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);
261
+ // Register window-relative bounds immediately (no setTimeout).
262
+ // measureInWindow itself is async; if it returns 0/0 dims, the
263
+ // next onLayout / onScroll will retry no race window.
264
+ measureAndRegister();
213
265
  onLayout?.(event);
214
266
  },
215
267
  [onLayout, measureAndRegister],
@@ -250,6 +250,20 @@ export class AppAnalytics {
250
250
  }
251
251
  }
252
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
+ */
253
267
  trackTouch(
254
268
  x: number,
255
269
  y: number,
@@ -266,30 +280,50 @@ export class AppAnalytics {
266
280
  }
267
281
 
268
282
  // 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;
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;
286
303
  }
287
304
  }
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;
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;
292
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;
293
327
  }
294
328
 
295
329
  const adjustedY = y + activeScrollOffset;
@@ -412,14 +446,21 @@ export class AppAnalytics {
412
446
  * Capture the current viewport as a tile for collaborative screen
413
447
  * building. Called by RepliqoScrollView on scroll stops.
414
448
  *
415
- * The tile includes the scrollOffsetY so the backend knows where
416
- * in the full content this viewport belongs. Multiple users/sessions
417
- * contribute tiles until the full content is covered.
449
+ * The tile includes:
450
+ * - `scrollOffsetY`: where in the full content this viewport belongs
451
+ * - `viewportTop`: Y position (window logical px) of the ScrollView's
452
+ * top edge. Used by the backend to crop each tile to just the
453
+ * scrollable area, removing fixed headers/footers that would
454
+ * otherwise duplicate in the composite.
455
+ * - `windowHeight`: window height in logical px, for image-to-logical
456
+ * scale computation during backend cropping.
418
457
  */
419
458
  captureScrollTile(
420
459
  scrollOffsetY: number,
421
460
  viewportHeight: number,
422
461
  contentHeight: number,
462
+ viewportTop?: number,
463
+ windowHeight?: number,
423
464
  ): void {
424
465
  if (!this.sessionId || !this.currentScreen) return;
425
466
  if (viewportHeight <= 0) return; // No real dimensions yet
@@ -436,6 +477,7 @@ export class AppAnalytics {
436
477
  this.logger.log(
437
478
  `Scroll tile: "${screenName}" Y=${Math.round(scrollOffsetY)} ` +
438
479
  `vp=${Math.round(viewportHeight)} content=${Math.round(contentHeight)} ` +
480
+ `top=${viewportTop !== undefined ? Math.round(viewportTop) : '?'} ` +
439
481
  `${Math.round(frame.image.length / 1024)}KB`,
440
482
  );
441
483
 
@@ -448,6 +490,8 @@ export class AppAnalytics {
448
490
  scrollOffsetY,
449
491
  viewportHeight,
450
492
  contentHeight,
493
+ viewportTop,
494
+ windowHeight,
451
495
  );
452
496
  } catch (err) {
453
497
  this.logger.warn('Scroll tile failed:', err);
@@ -229,6 +229,13 @@ export class ApiClient {
229
229
  */
230
230
  /**
231
231
  * Upload a scroll tile for collaborative screen building.
232
+ *
233
+ * @param viewportTop Y position (window logical px) of the ScrollView's
234
+ * top edge. Used by the backend to crop each tile to just the
235
+ * scrollable area, removing fixed headers/footers that would
236
+ * otherwise duplicate in the composite.
237
+ * @param windowHeight Window height (logical px) at capture time.
238
+ * Used by the backend to compute image-to-logical scale for cropping.
232
239
  */
233
240
  async uploadScreenTile(
234
241
  sessionId: string,
@@ -239,23 +246,33 @@ export class ApiClient {
239
246
  scrollOffsetY: number,
240
247
  viewportHeight: number,
241
248
  contentHeight: number,
249
+ viewportTop?: number,
250
+ windowHeight?: number,
242
251
  ): Promise<void> {
243
252
  try {
253
+ const body: Record<string, unknown> = {
254
+ sessionId,
255
+ screenName,
256
+ image,
257
+ width,
258
+ height,
259
+ scrollOffsetY: Math.round(scrollOffsetY),
260
+ viewportHeight: Math.round(viewportHeight),
261
+ contentHeight: Math.round(contentHeight),
262
+ };
263
+ if (typeof viewportTop === 'number' && Number.isFinite(viewportTop)) {
264
+ body.viewportTop = Math.round(viewportTop);
265
+ }
266
+ if (typeof windowHeight === 'number' && Number.isFinite(windowHeight)) {
267
+ body.windowHeight = Math.round(windowHeight);
268
+ }
269
+
244
270
  const response = await fetch(
245
271
  `${this.baseUrl}/api/screen-tiles`,
246
272
  {
247
273
  method: 'POST',
248
274
  headers: this.getHeaders(),
249
- body: JSON.stringify({
250
- sessionId,
251
- screenName,
252
- image,
253
- width,
254
- height,
255
- scrollOffsetY: Math.round(scrollOffsetY),
256
- viewportHeight: Math.round(viewportHeight),
257
- contentHeight: Math.round(contentHeight),
258
- }),
275
+ body: JSON.stringify(body),
259
276
  },
260
277
  );
261
278