@repliqo/sdk-react-native 0.3.3 → 0.3.4

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.
@@ -4,8 +4,8 @@ import { ScrollView, type ScrollViewProps } from 'react-native';
4
4
  * Drop-in replacement for React Native's ScrollView that:
5
5
  *
6
6
  * 1. Reports scroll offset for accurate heatmap touch coords
7
- * 2. Captures viewport tiles on scroll stops for collaborative
8
- * screen building — the backend composites these into a
9
- * full-content image over time from multiple user sessions
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
10
10
  */
11
11
  export declare const RepliqoScrollView: React.ForwardRefExoticComponent<ScrollViewProps & React.RefAttributes<ScrollView>>;
@@ -40,41 +40,54 @@ const client_1 = require("../core/client");
40
40
  /** Minimum scroll delta (px) to trigger a new tile capture. */
41
41
  const MIN_DELTA_PX = 20;
42
42
  /** Debounce: wait this long after scroll stops before capturing. */
43
- const DEBOUNCE_MS = 300;
43
+ const DEBOUNCE_MS = 250;
44
44
  /** Throttle: minimum time between tile captures. */
45
- const THROTTLE_MS = 500;
45
+ const THROTTLE_MS = 400;
46
+ /**
47
+ * During continuous scroll, force a capture every this many ms.
48
+ * Ensures tiles are captured even during fast, uninterrupted scrolls
49
+ * where the user never pauses long enough for the debounce to fire.
50
+ */
51
+ const SCROLL_INTERVAL_MS = 1200;
46
52
  /**
47
53
  * Drop-in replacement for React Native's ScrollView that:
48
54
  *
49
55
  * 1. Reports scroll offset for accurate heatmap touch coords
50
- * 2. Captures viewport tiles on scroll stops for collaborative
51
- * screen building — the backend composites these into a
52
- * full-content image over time from multiple user sessions
56
+ * 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
53
59
  */
54
- exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, ...props }, ref) => {
60
+ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
55
61
  const debounceTimer = (0, react_1.useRef)(null);
62
+ const scrollIntervalTimer = (0, react_1.useRef)(null);
56
63
  const lastCaptureTime = (0, react_1.useRef)(0);
57
64
  const lastCaptureY = (0, react_1.useRef)(-999);
58
65
  const initialCaptured = (0, react_1.useRef)(false);
66
+ const realContentHeight = (0, react_1.useRef)(0);
59
67
  const scrollState = (0, react_1.useRef)(null);
60
- // Cleanup debounce timer on unmount
68
+ // Cleanup timers on unmount
61
69
  (0, react_1.useEffect)(() => {
62
70
  return () => {
63
71
  if (debounceTimer.current)
64
72
  clearTimeout(debounceTimer.current);
73
+ if (scrollIntervalTimer.current)
74
+ clearInterval(scrollIntervalTimer.current);
65
75
  };
66
76
  }, []);
67
77
  /**
68
78
  * Core tile capture logic. Reads from refs (stable across renders).
69
79
  * Checks throttle + delta before capturing.
80
+ *
81
+ * @param force Skip throttle check (used for interval captures)
70
82
  */
71
- const doCapture = () => {
83
+ const doCapture = (force = false) => {
72
84
  const state = scrollState.current;
73
85
  if (!state || state.viewportHeight <= 0)
74
86
  return;
75
87
  const now = Date.now();
76
- if (now - lastCaptureTime.current < THROTTLE_MS)
88
+ if (!force && now - lastCaptureTime.current < THROTTLE_MS)
77
89
  return;
90
+ // Always check delta — no point re-capturing the same position
78
91
  if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
79
92
  initialCaptured.current)
80
93
  return;
@@ -86,6 +99,21 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
86
99
  }
87
100
  catch { }
88
101
  };
102
+ /** Start the interval timer for continuous scroll captures. */
103
+ const startScrollInterval = () => {
104
+ if (scrollIntervalTimer.current !== null)
105
+ return;
106
+ scrollIntervalTimer.current = setInterval(() => {
107
+ doCapture(true); // force=true skips throttle
108
+ }, SCROLL_INTERVAL_MS);
109
+ };
110
+ /** Stop the interval timer. */
111
+ const stopScrollInterval = () => {
112
+ if (scrollIntervalTimer.current !== null) {
113
+ clearInterval(scrollIntervalTimer.current);
114
+ scrollIntervalTimer.current = null;
115
+ }
116
+ };
89
117
  const handleScroll = (0, react_1.useCallback)((event) => {
90
118
  const { contentOffset, layoutMeasurement, contentSize } = event.nativeEvent;
91
119
  // Report scroll offset for touch coords
@@ -93,25 +121,31 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
93
121
  client_1.AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
94
122
  }
95
123
  catch { }
96
- // Update scroll state
124
+ // Update scroll state — use realContentHeight if available
125
+ const bestContentHeight = realContentHeight.current > 0
126
+ ? realContentHeight.current
127
+ : contentSize.height;
97
128
  scrollState.current = {
98
129
  offsetY: contentOffset.y,
99
130
  viewportHeight: layoutMeasurement.height,
100
- contentHeight: contentSize.height,
131
+ contentHeight: bestContentHeight,
101
132
  };
102
133
  // Debounce: capture after scroll stops
103
134
  if (debounceTimer.current)
104
135
  clearTimeout(debounceTimer.current);
105
- debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
106
- // Capture initial tile (Y≈0) on first scroll event with real dimensions
136
+ debounceTimer.current = setTimeout(() => doCapture(), DEBOUNCE_MS);
137
+ // Start interval for continuous scroll captures
138
+ startScrollInterval();
139
+ // Capture initial tile (Y~0) on first scroll event with real dims
107
140
  if (!initialCaptured.current && contentOffset.y < 10) {
108
- setTimeout(doCapture, 200);
141
+ setTimeout(() => doCapture(), 200);
109
142
  }
110
143
  onScroll?.(event);
111
144
  }, [onScroll]);
112
145
  const handleScrollEndDrag = (0, react_1.useCallback)((event) => {
113
146
  // User lifted finger — capture immediately
114
- doCapture();
147
+ stopScrollInterval();
148
+ doCapture(true);
115
149
  onScrollEndDrag?.(event);
116
150
  }, [onScrollEndDrag]);
117
151
  const handleMomentumEnd = (0, react_1.useCallback)((event) => {
@@ -119,7 +153,8 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
119
153
  clearTimeout(debounceTimer.current);
120
154
  debounceTimer.current = null;
121
155
  }
122
- doCapture();
156
+ stopScrollInterval();
157
+ doCapture(true);
123
158
  onMomentumScrollEnd?.(event);
124
159
  }, [onMomentumScrollEnd]);
125
160
  const handleLayout = (0, react_1.useCallback)((event) => {
@@ -129,12 +164,25 @@ exports.RepliqoScrollView = react_1.default.forwardRef(({ onScroll, onMomentumSc
129
164
  scrollState.current = {
130
165
  offsetY: 0,
131
166
  viewportHeight: height,
132
- contentHeight: height, // Will be updated on first scroll
167
+ contentHeight: realContentHeight.current || height,
133
168
  };
134
- setTimeout(doCapture, 1000);
169
+ setTimeout(() => doCapture(), 1000);
135
170
  }
136
171
  onLayout?.(event);
137
172
  }, [onLayout]);
138
- return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
173
+ /**
174
+ * Track the real content height from onContentSizeChange.
175
+ * This is more reliable than the first scroll event and ensures
176
+ * the initial tile at Y=0 has correct contentHeight metadata.
177
+ */
178
+ const handleContentSizeChange = (0, react_1.useCallback)((w, h) => {
179
+ realContentHeight.current = h;
180
+ // Update scroll state if it exists
181
+ if (scrollState.current) {
182
+ scrollState.current.contentHeight = h;
183
+ }
184
+ onContentSizeChange?.(w, h);
185
+ }, [onContentSizeChange]);
186
+ return (<react_native_1.ScrollView ref={ref} {...props} onScroll={handleScroll} onScrollEndDrag={handleScrollEndDrag} onMomentumScrollEnd={handleMomentumEnd} onLayout={handleLayout} onContentSizeChange={handleContentSizeChange} scrollEventThrottle={props.scrollEventThrottle ?? 16}/>);
139
187
  });
140
188
  exports.RepliqoScrollView.displayName = 'RepliqoScrollView';
@@ -284,16 +284,14 @@ class AppAnalytics {
284
284
  this.scrollOffsetY = 0;
285
285
  this.errorTracker?.setCurrentScreen(screenName);
286
286
  this.snapshotCapture?.setCurrentScreen(screenName);
287
- // Auto-scan with PixelCopy: captures entire ScrollView content
288
- // by scrolling programmatically + PixelCopy from GPU framebuffer.
289
- // Fires 2s after screen enter to let content fully render.
290
- if (this.config.enableHeatmapCapture) {
291
- setTimeout(() => {
292
- if (this.currentScreen !== screenName || !this.sessionId)
293
- return;
294
- this.autoScanScreen(screenName);
295
- }, 2000);
296
- }
287
+ // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
288
+ // React Native does NOT render off-screen content to the GPU or view
289
+ // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
290
+ // tiles. Worse, those blank tiles poison the dedup cache and block
291
+ // real tiles captured during natural user scroll.
292
+ //
293
+ // Full-content capture relies entirely on RepliqoScrollView capturing
294
+ // viewport tiles as the user naturally scrolls through the content.
297
295
  this.logger.log('Screen entered:', screenName);
298
296
  }
299
297
  onScreenExit(screenName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repliqo/sdk-react-native",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
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",
@@ -11,47 +11,60 @@ import { AppAnalytics } from '../core/client';
11
11
  /** Minimum scroll delta (px) to trigger a new tile capture. */
12
12
  const MIN_DELTA_PX = 20;
13
13
  /** Debounce: wait this long after scroll stops before capturing. */
14
- const DEBOUNCE_MS = 300;
14
+ const DEBOUNCE_MS = 250;
15
15
  /** Throttle: minimum time between tile captures. */
16
- const THROTTLE_MS = 500;
16
+ const THROTTLE_MS = 400;
17
+ /**
18
+ * During continuous scroll, force a capture every this many ms.
19
+ * Ensures tiles are captured even during fast, uninterrupted scrolls
20
+ * where the user never pauses long enough for the debounce to fire.
21
+ */
22
+ const SCROLL_INTERVAL_MS = 1200;
17
23
 
18
24
  /**
19
25
  * Drop-in replacement for React Native's ScrollView that:
20
26
  *
21
27
  * 1. Reports scroll offset for accurate heatmap touch coords
22
- * 2. Captures viewport tiles on scroll stops for collaborative
23
- * screen building — the backend composites these into a
24
- * full-content image over time from multiple user sessions
28
+ * 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
25
31
  */
26
32
  export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
27
- ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, ...props }, ref) => {
33
+ ({ onScroll, onMomentumScrollEnd, onScrollEndDrag, onLayout, onContentSizeChange, ...props }, ref) => {
28
34
  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
35
+ const scrollIntervalTimer = useRef<ReturnType<typeof setInterval> | null>(null);
29
36
  const lastCaptureTime = useRef(0);
30
37
  const lastCaptureY = useRef(-999);
31
38
  const initialCaptured = useRef(false);
39
+ const realContentHeight = useRef(0);
32
40
  const scrollState = useRef<{
33
41
  offsetY: number;
34
42
  viewportHeight: number;
35
43
  contentHeight: number;
36
44
  } | null>(null);
37
45
 
38
- // Cleanup debounce timer on unmount
46
+ // Cleanup timers on unmount
39
47
  useEffect(() => {
40
48
  return () => {
41
49
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
50
+ if (scrollIntervalTimer.current) clearInterval(scrollIntervalTimer.current);
42
51
  };
43
52
  }, []);
44
53
 
45
54
  /**
46
55
  * Core tile capture logic. Reads from refs (stable across renders).
47
56
  * Checks throttle + delta before capturing.
57
+ *
58
+ * @param force Skip throttle check (used for interval captures)
48
59
  */
49
- const doCapture = () => {
60
+ const doCapture = (force = false) => {
50
61
  const state = scrollState.current;
51
62
  if (!state || state.viewportHeight <= 0) return;
52
63
 
53
64
  const now = Date.now();
54
- if (now - lastCaptureTime.current < THROTTLE_MS) return;
65
+ if (!force && now - lastCaptureTime.current < THROTTLE_MS) return;
66
+
67
+ // Always check delta — no point re-capturing the same position
55
68
  if (Math.abs(state.offsetY - lastCaptureY.current) < MIN_DELTA_PX &&
56
69
  initialCaptured.current) return;
57
70
 
@@ -68,6 +81,22 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
68
81
  } catch {}
69
82
  };
70
83
 
84
+ /** Start the interval timer for continuous scroll captures. */
85
+ const startScrollInterval = () => {
86
+ if (scrollIntervalTimer.current !== null) return;
87
+ scrollIntervalTimer.current = setInterval(() => {
88
+ doCapture(true); // force=true skips throttle
89
+ }, SCROLL_INTERVAL_MS);
90
+ };
91
+
92
+ /** Stop the interval timer. */
93
+ const stopScrollInterval = () => {
94
+ if (scrollIntervalTimer.current !== null) {
95
+ clearInterval(scrollIntervalTimer.current);
96
+ scrollIntervalTimer.current = null;
97
+ }
98
+ };
99
+
71
100
  const handleScroll = useCallback(
72
101
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
73
102
  const { contentOffset, layoutMeasurement, contentSize } =
@@ -78,20 +107,26 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
78
107
  AppAnalytics.getInstance().setScrollOffset(contentOffset.y);
79
108
  } catch {}
80
109
 
81
- // Update scroll state
110
+ // Update scroll state — use realContentHeight if available
111
+ const bestContentHeight = realContentHeight.current > 0
112
+ ? realContentHeight.current
113
+ : contentSize.height;
82
114
  scrollState.current = {
83
115
  offsetY: contentOffset.y,
84
116
  viewportHeight: layoutMeasurement.height,
85
- contentHeight: contentSize.height,
117
+ contentHeight: bestContentHeight,
86
118
  };
87
119
 
88
120
  // Debounce: capture after scroll stops
89
121
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
90
- debounceTimer.current = setTimeout(doCapture, DEBOUNCE_MS);
122
+ debounceTimer.current = setTimeout(() => doCapture(), DEBOUNCE_MS);
91
123
 
92
- // Capture initial tile (Y≈0) on first scroll event with real dimensions
124
+ // Start interval for continuous scroll captures
125
+ startScrollInterval();
126
+
127
+ // Capture initial tile (Y~0) on first scroll event with real dims
93
128
  if (!initialCaptured.current && contentOffset.y < 10) {
94
- setTimeout(doCapture, 200);
129
+ setTimeout(() => doCapture(), 200);
95
130
  }
96
131
 
97
132
  onScroll?.(event);
@@ -102,7 +137,8 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
102
137
  const handleScrollEndDrag = useCallback(
103
138
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
104
139
  // User lifted finger — capture immediately
105
- doCapture();
140
+ stopScrollInterval();
141
+ doCapture(true);
106
142
  onScrollEndDrag?.(event);
107
143
  },
108
144
  [onScrollEndDrag],
@@ -114,7 +150,8 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
114
150
  clearTimeout(debounceTimer.current);
115
151
  debounceTimer.current = null;
116
152
  }
117
- doCapture();
153
+ stopScrollInterval();
154
+ doCapture(true);
118
155
  onMomentumScrollEnd?.(event);
119
156
  },
120
157
  [onMomentumScrollEnd],
@@ -128,15 +165,32 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
128
165
  scrollState.current = {
129
166
  offsetY: 0,
130
167
  viewportHeight: height,
131
- contentHeight: height, // Will be updated on first scroll
168
+ contentHeight: realContentHeight.current || height,
132
169
  };
133
- setTimeout(doCapture, 1000);
170
+ setTimeout(() => doCapture(), 1000);
134
171
  }
135
172
  onLayout?.(event);
136
173
  },
137
174
  [onLayout],
138
175
  );
139
176
 
177
+ /**
178
+ * Track the real content height from onContentSizeChange.
179
+ * This is more reliable than the first scroll event and ensures
180
+ * the initial tile at Y=0 has correct contentHeight metadata.
181
+ */
182
+ const handleContentSizeChange = useCallback(
183
+ (w: number, h: number) => {
184
+ realContentHeight.current = h;
185
+ // Update scroll state if it exists
186
+ if (scrollState.current) {
187
+ scrollState.current.contentHeight = h;
188
+ }
189
+ onContentSizeChange?.(w, h);
190
+ },
191
+ [onContentSizeChange],
192
+ );
193
+
140
194
  return (
141
195
  <ScrollView
142
196
  ref={ref}
@@ -145,6 +199,7 @@ export const RepliqoScrollView = React.forwardRef<ScrollView, ScrollViewProps>(
145
199
  onScrollEndDrag={handleScrollEndDrag}
146
200
  onMomentumScrollEnd={handleMomentumEnd}
147
201
  onLayout={handleLayout}
202
+ onContentSizeChange={handleContentSizeChange}
148
203
  scrollEventThrottle={props.scrollEventThrottle ?? 16}
149
204
  />
150
205
  );
@@ -416,15 +416,14 @@ export class AppAnalytics {
416
416
  this.scrollOffsetY = 0;
417
417
  this.errorTracker?.setCurrentScreen(screenName);
418
418
  this.snapshotCapture?.setCurrentScreen(screenName);
419
- // Auto-scan with PixelCopy: captures entire ScrollView content
420
- // by scrolling programmatically + PixelCopy from GPU framebuffer.
421
- // Fires 2s after screen enter to let content fully render.
422
- if (this.config.enableHeatmapCapture) {
423
- setTimeout(() => {
424
- if (this.currentScreen !== screenName || !this.sessionId) return;
425
- this.autoScanScreen(screenName);
426
- }, 2000);
427
- }
419
+ // NOTE: autoScanScreen (PixelCopy / programmatic scroll) is DISABLED.
420
+ // React Native does NOT render off-screen content to the GPU or view
421
+ // tree, so programmatic scroll + View.draw()/PixelCopy produces blank
422
+ // tiles. Worse, those blank tiles poison the dedup cache and block
423
+ // real tiles captured during natural user scroll.
424
+ //
425
+ // Full-content capture relies entirely on RepliqoScrollView capturing
426
+ // viewport tiles as the user naturally scrolls through the content.
428
427
 
429
428
  this.logger.log('Screen entered:', screenName);
430
429
  }