@repliqo/sdk-react-native 0.3.5 → 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.
@@ -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,24 @@ 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);
73
82
  const scrollState = (0, react_1.useRef)(null);
74
- // Forward the inner ref to the parent's ref (if provided)
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.
75
87
  const setRef = (0, react_1.useCallback)((node) => {
76
- innerRef.current = node;
88
+ if (node !== null) {
89
+ innerRef.current = node;
90
+ }
77
91
  if (typeof ref === 'function') {
78
92
  ref(node);
79
93
  }
@@ -81,12 +95,21 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
81
95
  ref.current = node;
82
96
  }
83
97
  }, [ref]);
84
- /** Measure window-relative bounds and register with SDK. */
98
+ /**
99
+ * Measure window-relative bounds and register with SDK.
100
+ * Idempotent + safe to call after unmount (guards via isMounted).
101
+ */
85
102
  const measureAndRegister = (0, react_1.useCallback)(() => {
103
+ if (!isMounted.current)
104
+ return;
86
105
  const node = innerRef.current;
87
106
  if (!node || typeof node.measureInWindow !== 'function')
88
107
  return;
89
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.
90
113
  if (width <= 0 || height <= 0)
91
114
  return;
92
115
  try {
@@ -97,20 +120,29 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
97
120
  catch { }
98
121
  });
99
122
  }, []);
100
- // Cleanup timers + unregister on unmount
123
+ // Cleanup all timers + unregister + listeners on unmount
101
124
  (0, react_1.useEffect)(() => {
102
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);
103
130
  return () => {
131
+ isMounted.current = false;
104
132
  if (debounceTimer.current)
105
133
  clearTimeout(debounceTimer.current);
106
134
  if (scrollIntervalTimer.current)
107
135
  clearInterval(scrollIntervalTimer.current);
136
+ if (initialLayoutTimer.current)
137
+ clearTimeout(initialLayoutTimer.current);
138
+ showSub.remove();
139
+ hideSub.remove();
108
140
  try {
109
141
  client_1.AppAnalytics.getInstance().unregisterScrollView(id);
110
142
  }
111
143
  catch { }
112
144
  };
113
- }, []);
145
+ }, [measureAndRegister]);
114
146
  /**
115
147
  * Core tile capture logic. Reads from refs (stable across renders).
116
148
  * Checks throttle + delta before capturing.
@@ -118,6 +150,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
118
150
  * @param force Skip throttle check (used for interval captures)
119
151
  */
120
152
  const doCapture = (force = false) => {
153
+ if (!isMounted.current)
154
+ return;
121
155
  const state = scrollState.current;
122
156
  if (!state || state.viewportHeight <= 0)
123
157
  return;
@@ -160,6 +194,13 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
160
194
  sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
161
195
  }
162
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
+ }
163
204
  // Update scroll state — use realContentHeight if available
164
205
  const bestContentHeight = realContentHeight.current > 0
165
206
  ? realContentHeight.current
@@ -177,10 +218,12 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
177
218
  startScrollInterval();
178
219
  // Capture initial tile (Y~0) on first scroll event with real dims
179
220
  if (!initialCaptured.current && contentOffset.y < 10) {
180
- setTimeout(() => doCapture(), 200);
221
+ if (initialLayoutTimer.current)
222
+ clearTimeout(initialLayoutTimer.current);
223
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
181
224
  }
182
225
  onScroll?.(event);
183
- }, [onScroll]);
226
+ }, [onScroll, measureAndRegister]);
184
227
  const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
185
228
  // User lifted finger — capture immediately
186
229
  stopScrollInterval();
@@ -197,7 +240,6 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
197
240
  onMomentumScrollEnd?.(event);
198
241
  }, [onMomentumScrollEnd]);
199
242
  const handleLayout = (0, react_1.useCallback)((event) => {
200
- // Capture initial tile after layout (gets real viewportHeight)
201
243
  const { height } = event.nativeEvent.layout;
202
244
  if (!initialCaptured.current && height > 0) {
203
245
  scrollState.current = {
@@ -205,12 +247,15 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
205
247
  viewportHeight: height,
206
248
  contentHeight: realContentHeight.current || height,
207
249
  };
208
- 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);
209
254
  }
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);
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();
214
259
  onLayout?.(event);
215
260
  }, [onLayout, measureAndRegister]);
216
261
  /**
@@ -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;
@@ -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',
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.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,6 +21,12 @@ 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;
23
30
 
24
31
  let scrollViewIdCounter = 0;
25
32
  const nextScrollViewId = () => `repliqo-scroll-${++scrollViewIdCounter}`;
@@ -38,21 +45,29 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
38
45
  ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
39
46
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
40
47
  const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
48
+ const initialLayoutTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
41
49
  const lastCaptureTime = useRef(0);
42
50
  const lastCaptureY = useRef(-999);
51
+ const lastMeasureTime = useRef(0);
43
52
  const initialCaptured = useRef(false);
44
53
  const realContentHeight = useRef(0);
45
54
  const scrollViewId = useRef<string>(nextScrollViewId());
46
55
  const innerRef = useRef<ScrollView | null>(null);
56
+ const isMounted = useRef(true);
47
57
  const scrollState = useRef<{
48
58
  offsetY: number;
49
59
  viewportHeight: number;
50
60
  contentHeight: number;
51
61
  } | null>(null);
52
62
 
53
- // Forward the inner ref to the parent's ref (if provided)
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.
54
67
  const setRef = useCallback((node: ScrollView | null) => {
55
- innerRef.current = node;
68
+ if (node !== null) {
69
+ innerRef.current = node;
70
+ }
56
71
  if (typeof ref === 'function') {
57
72
  ref(node);
58
73
  } else if (ref) {
@@ -60,11 +75,18 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
60
75
  }
61
76
  }, [ref]);
62
77
 
63
- /** Measure window-relative bounds and register with SDK. */
78
+ /**
79
+ * Measure window-relative bounds and register with SDK.
80
+ * Idempotent + safe to call after unmount (guards via isMounted).
81
+ */
64
82
  const measureAndRegister = useCallback(() => {
83
+ if (!isMounted.current) return;
65
84
  const node = innerRef.current as any;
66
85
  if (!node || typeof node.measureInWindow !== 'function') return;
67
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.
68
90
  if (width <= 0 || height <= 0) return;
69
91
  try {
70
92
  AppAnalytics.getInstance().registerScrollView(scrollViewId.current, {
@@ -74,17 +96,27 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
74
96
  });
75
97
  }, []);
76
98
 
77
- // Cleanup timers + unregister on unmount
99
+ // Cleanup all timers + unregister + listeners on unmount
78
100
  useEffect(() => {
79
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
+
80
108
  return () => {
109
+ isMounted.current = false;
81
110
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
82
111
  if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
112
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
113
+ showSub.remove();
114
+ hideSub.remove();
83
115
  try {
84
116
  AppAnalytics.getInstance().unregisterScrollView(id);
85
117
  } catch {}
86
118
  };
87
- }, []);
119
+ }, [measureAndRegister]);
88
120
 
89
121
  /**
90
122
  * Core tile capture logic. Reads from refs (stable across renders).
@@ -93,6 +125,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
93
125
  * @param force Skip throttle check (used for interval captures)
94
126
  */
95
127
  const doCapture = (force = false) => {
128
+ if (!isMounted.current) return;
96
129
  const state = scrollState.current;
97
130
  if (!state || state.viewportHeight <= 0) return;
98
131
 
@@ -144,6 +177,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
144
177
  sdk.updateScrollViewOffset(scrollViewId.current, contentOffset.y);
145
178
  } catch {}
146
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
+
147
188
  // Update scroll state — use realContentHeight if available
148
189
  const bestContentHeight = realContentHeight.current > 0
149
190
  ? realContentHeight.current
@@ -163,12 +204,13 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
163
204
 
164
205
  // Capture initial tile (Y~0) on first scroll event with real dims
165
206
  if (!initialCaptured.current && contentOffset.y < 10) {
166
- setTimeout(() => doCapture(), 200);
207
+ if (initialLayoutTimer.current) clearTimeout(initialLayoutTimer.current);
208
+ initialLayoutTimer.current = setTimeout(() => doCapture(), 200);
167
209
  }
168
210
 
169
211
  onScroll?.(event);
170
212
  },
171
- [onScroll],
213
+ [onScroll, measureAndRegister],
172
214
  );
173
215
 
174
216
  const handleScrollEndDrag = useCallback(
@@ -196,7 +238,6 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
196
238
 
197
239
  const handleLayout = useCallback(
198
240
  (event: LayoutChangeEvent) => {
199
- // Capture initial tile after layout (gets real viewportHeight)
200
241
  const { height } = event.nativeEvent.layout;
201
242
  if (!initialCaptured.current && height > 0) {
202
243
  scrollState.current = {
@@ -204,12 +245,14 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
204
245
  viewportHeight: height,
205
246
  contentHeight: realContentHeight.current || height,
206
247
  };
207
- 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);
208
251
  }
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);
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();
213
256
  onLayout?.(event);
214
257
  },
215
258
  [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;