@brett_lamy/docstream 1.2.3 → 1.3.0

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.
@@ -0,0 +1,236 @@
1
+ /* The rrweb player. Loaded on demand by ReplayPreview, so pages that only embed Loop QA
2
+ iframes (the markdown `{% embed %}` case) never download rrweb or its stylesheet. */
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { EventType, Replayer } from 'rrweb';
5
+ import type { eventWithTime } from 'rrweb';
6
+ import { sanitizeReplayEvent } from './sanitize';
7
+ import 'rrweb/dist/style.css';
8
+
9
+ const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
10
+ const SKIP_IDLE_THRESHOLD_MS = 5_000;
11
+
12
+ export default function NativeReplay({
13
+ events,
14
+ autoplay = false,
15
+ loop = false,
16
+ speeds = DEFAULT_SPEEDS,
17
+ }: {
18
+ events: eventWithTime[];
19
+ autoplay?: boolean;
20
+ loop?: boolean;
21
+ speeds?: number[];
22
+ }) {
23
+ const stageRef = useRef<HTMLDivElement>(null);
24
+ const playerRef = useRef<HTMLDivElement>(null);
25
+ const replayerRef = useRef<Replayer | null>(null);
26
+ const fedCountRef = useRef(0);
27
+ const [playing, setPlaying] = useState(false);
28
+ const [speed, setSpeed] = useState(1);
29
+ const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
30
+ const [scale, setScale] = useState(1);
31
+ const [currentTime, setCurrentTime] = useState(0);
32
+ const [totalTime, setTotalTime] = useState(0);
33
+ const [scrubbing, setScrubbing] = useState<number | null>(null);
34
+ const speedRef = useRef(speed);
35
+ const loopRef = useRef(loop);
36
+ const autoplayRef = useRef(autoplay);
37
+
38
+ const prepared = useMemo(
39
+ () => events.map((event) => sanitizeReplayEvent(event)).sort((a, b) => a.timestamp - b.timestamp),
40
+ [events],
41
+ );
42
+ const hasSnapshot = prepared.some((event) => event.type === EventType.FullSnapshot);
43
+
44
+ useEffect(() => {
45
+ if (replayerRef.current && prepared.length < fedCountRef.current) {
46
+ try {
47
+ replayerRef.current.destroy();
48
+ } catch {
49
+ // Ignore stale player teardown.
50
+ }
51
+ replayerRef.current = null;
52
+ fedCountRef.current = 0;
53
+ }
54
+ if (!hasSnapshot || !playerRef.current) return;
55
+
56
+ if (!replayerRef.current) {
57
+ const replayer = new Replayer(prepared, {
58
+ root: playerRef.current,
59
+ liveMode: false,
60
+ speed: speedRef.current,
61
+ skipInactive: true,
62
+ inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
63
+ mouseTail: false,
64
+ showWarning: false,
65
+ });
66
+ replayer.on('resize', (payload) => {
67
+ const size = payload as { width?: number; height?: number };
68
+ if (size.width && size.height) setDims({ w: size.width, h: size.height });
69
+ });
70
+ replayer.on('finish', () => {
71
+ if (loopRef.current) {
72
+ replayer.play(0);
73
+ setPlaying(true);
74
+ } else {
75
+ setPlaying(false);
76
+ }
77
+ });
78
+ replayer.pause(0);
79
+ replayerRef.current = replayer;
80
+ fedCountRef.current = prepared.length;
81
+ if (autoplayRef.current) {
82
+ replayer.play(0);
83
+ setPlaying(true);
84
+ }
85
+ } else if (prepared.length > fedCountRef.current) {
86
+ for (let i = fedCountRef.current; i < prepared.length; i++) {
87
+ replayerRef.current.addEvent(prepared[i]!);
88
+ }
89
+ fedCountRef.current = prepared.length;
90
+ }
91
+ }, [hasSnapshot, prepared]);
92
+
93
+ useEffect(
94
+ () => () => {
95
+ try {
96
+ replayerRef.current?.destroy();
97
+ } catch {
98
+ // Ignore unmount teardown errors from rrweb.
99
+ }
100
+ replayerRef.current = null;
101
+ fedCountRef.current = 0;
102
+ },
103
+ [],
104
+ );
105
+
106
+ useEffect(() => {
107
+ speedRef.current = speed;
108
+ replayerRef.current?.setConfig({ speed });
109
+ }, [speed]);
110
+
111
+ useEffect(() => {
112
+ loopRef.current = loop;
113
+ }, [loop]);
114
+
115
+ useEffect(() => {
116
+ autoplayRef.current = autoplay;
117
+ }, [autoplay]);
118
+
119
+ const recalcScale = useCallback(() => {
120
+ if (!dims || !stageRef.current || dims.w <= 0) return;
121
+ setScale(Math.min(1, stageRef.current.clientWidth / dims.w));
122
+ }, [dims]);
123
+
124
+ useEffect(() => {
125
+ recalcScale();
126
+ window.addEventListener('resize', recalcScale);
127
+ return () => window.removeEventListener('resize', recalcScale);
128
+ }, [recalcScale]);
129
+
130
+ useEffect(() => {
131
+ if (!hasSnapshot) return;
132
+ const interval = window.setInterval(() => {
133
+ const replayer = replayerRef.current;
134
+ if (!replayer) return;
135
+ try {
136
+ const total = replayer.getMetaData().totalTime;
137
+ const current = Math.max(0, Math.min(replayer.getCurrentTime(), total));
138
+ setTotalTime(total);
139
+ if (scrubbing === null) setCurrentTime(current);
140
+ } catch {
141
+ // rrweb can briefly be unready while a full snapshot is applied.
142
+ }
143
+ }, 200);
144
+ return () => window.clearInterval(interval);
145
+ }, [hasSnapshot, scrubbing]);
146
+
147
+ const seek = useCallback(
148
+ (value: number) => {
149
+ const replayer = replayerRef.current;
150
+ if (!replayer) return;
151
+ const next = Math.max(0, Math.min(value, totalTime));
152
+ if (playing) replayer.play(next);
153
+ else replayer.pause(next);
154
+ setCurrentTime(next);
155
+ },
156
+ [playing, totalTime],
157
+ );
158
+
159
+ const toggle = () => {
160
+ const replayer = replayerRef.current;
161
+ if (!replayer) return;
162
+ if (playing) {
163
+ replayer.pause();
164
+ setPlaying(false);
165
+ return;
166
+ }
167
+ let offset = currentTime;
168
+ try {
169
+ if (offset >= replayer.getMetaData().totalTime - 1) offset = 0;
170
+ } catch {
171
+ offset = 0;
172
+ }
173
+ replayer.play(offset);
174
+ setPlaying(true);
175
+ };
176
+
177
+ const restart = () => {
178
+ replayerRef.current?.play(0);
179
+ setCurrentTime(0);
180
+ setPlaying(true);
181
+ };
182
+
183
+ if (!hasSnapshot) {
184
+ return (
185
+ <div className="replay-preview__empty">
186
+ {events.length ? `Captured ${events.length} events, but no full DOM snapshot is available.` : 'Replay data is not available yet.'}
187
+ </div>
188
+ );
189
+ }
190
+
191
+ return (
192
+ <div className="replay-preview__native">
193
+ <div
194
+ ref={stageRef}
195
+ className="replay-preview__stage"
196
+ style={dims ? { height: Math.max(1, Math.round(dims.h * scale)) } : undefined}
197
+ >
198
+ <div
199
+ className="replay-preview__scaler"
200
+ style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
201
+ >
202
+ <div ref={playerRef} className="replay-preview__player" aria-hidden="true" />
203
+ </div>
204
+ </div>
205
+ <div className="replay-preview__controls">
206
+ <button type="button" onClick={toggle} aria-label={playing ? 'Pause replay' : 'Play replay'}>
207
+ {playing ? 'Pause' : 'Play'}
208
+ </button>
209
+ <button type="button" onClick={restart} aria-label="Restart replay">
210
+ Restart
211
+ </button>
212
+ <input
213
+ type="range"
214
+ min={0}
215
+ max={Math.max(totalTime, 1)}
216
+ step={1}
217
+ value={scrubbing ?? currentTime}
218
+ onChange={(event) => setScrubbing(Number(event.target.value))}
219
+ onPointerUp={(event) => {
220
+ seek(Number((event.currentTarget as HTMLInputElement).value));
221
+ setScrubbing(null);
222
+ }}
223
+ aria-label="Replay position"
224
+ />
225
+ <select value={speed} onChange={(event) => setSpeed(Number(event.target.value))} aria-label="Replay speed">
226
+ {speeds.map((option) => (
227
+ <option key={option} value={option}>
228
+ {option}×
229
+ </option>
230
+ ))}
231
+ </select>
232
+ </div>
233
+ </div>
234
+ );
235
+ }
236
+
@@ -1,9 +1,6 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
- import { EventType, Replayer } from 'rrweb';
1
+ import { Suspense, lazy, useEffect, useState } from 'react';
3
2
  import type { eventWithTime } from 'rrweb';
4
3
  import { normalizeReplayEmbedUrl } from './url';
5
- import { sanitizeReplayEvent } from './sanitize';
6
- import 'rrweb/dist/style.css';
7
4
  import './styles.css';
8
5
 
9
6
  export interface ReplayPageSource {
@@ -45,7 +42,6 @@ export interface ReplayPreviewProps {
45
42
  }
46
43
 
47
44
  const DEFAULT_SPEEDS = [0.5, 1, 2, 4, 8];
48
- const SKIP_IDLE_THRESHOLD_MS = 5_000;
49
45
 
50
46
  function sourcePage(source: ReplaySource): ReplayPageSource | null {
51
47
  if (typeof source === 'string') return { kind: 'url', url: source };
@@ -86,228 +82,14 @@ function ReplayFrame({ source, title }: { source: ReplayPageSource; title?: stri
86
82
  );
87
83
  }
88
84
 
89
- function NativeReplay({
90
- events,
91
- autoplay = false,
92
- loop = false,
93
- speeds = DEFAULT_SPEEDS,
94
- }: {
95
- events: eventWithTime[];
96
- autoplay?: boolean;
97
- loop?: boolean;
98
- speeds?: number[];
99
- }) {
100
- const stageRef = useRef<HTMLDivElement>(null);
101
- const playerRef = useRef<HTMLDivElement>(null);
102
- const replayerRef = useRef<Replayer | null>(null);
103
- const fedCountRef = useRef(0);
104
- const [playing, setPlaying] = useState(false);
105
- const [speed, setSpeed] = useState(1);
106
- const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
107
- const [scale, setScale] = useState(1);
108
- const [currentTime, setCurrentTime] = useState(0);
109
- const [totalTime, setTotalTime] = useState(0);
110
- const [scrubbing, setScrubbing] = useState<number | null>(null);
111
- const speedRef = useRef(speed);
112
- const loopRef = useRef(loop);
113
- const autoplayRef = useRef(autoplay);
114
-
115
- const prepared = useMemo(
116
- () => events.map((event) => sanitizeReplayEvent(event)).sort((a, b) => a.timestamp - b.timestamp),
117
- [events],
118
- );
119
- const hasSnapshot = prepared.some((event) => event.type === EventType.FullSnapshot);
120
-
121
- useEffect(() => {
122
- if (replayerRef.current && prepared.length < fedCountRef.current) {
123
- try {
124
- replayerRef.current.destroy();
125
- } catch {
126
- // Ignore stale player teardown.
127
- }
128
- replayerRef.current = null;
129
- fedCountRef.current = 0;
130
- }
131
- if (!hasSnapshot || !playerRef.current) return;
132
-
133
- if (!replayerRef.current) {
134
- const replayer = new Replayer(prepared, {
135
- root: playerRef.current,
136
- liveMode: false,
137
- speed: speedRef.current,
138
- skipInactive: true,
139
- inactivePeriodThreshold: SKIP_IDLE_THRESHOLD_MS,
140
- mouseTail: false,
141
- showWarning: false,
142
- });
143
- replayer.on('resize', (payload) => {
144
- const size = payload as { width?: number; height?: number };
145
- if (size.width && size.height) setDims({ w: size.width, h: size.height });
146
- });
147
- replayer.on('finish', () => {
148
- if (loopRef.current) {
149
- replayer.play(0);
150
- setPlaying(true);
151
- } else {
152
- setPlaying(false);
153
- }
154
- });
155
- replayer.pause(0);
156
- replayerRef.current = replayer;
157
- fedCountRef.current = prepared.length;
158
- if (autoplayRef.current) {
159
- replayer.play(0);
160
- setPlaying(true);
161
- }
162
- } else if (prepared.length > fedCountRef.current) {
163
- for (let i = fedCountRef.current; i < prepared.length; i++) {
164
- replayerRef.current.addEvent(prepared[i]!);
165
- }
166
- fedCountRef.current = prepared.length;
167
- }
168
- }, [hasSnapshot, prepared]);
169
-
170
- useEffect(
171
- () => () => {
172
- try {
173
- replayerRef.current?.destroy();
174
- } catch {
175
- // Ignore unmount teardown errors from rrweb.
176
- }
177
- replayerRef.current = null;
178
- fedCountRef.current = 0;
179
- },
180
- [],
181
- );
182
-
183
- useEffect(() => {
184
- speedRef.current = speed;
185
- replayerRef.current?.setConfig({ speed });
186
- }, [speed]);
187
-
188
- useEffect(() => {
189
- loopRef.current = loop;
190
- }, [loop]);
191
-
192
- useEffect(() => {
193
- autoplayRef.current = autoplay;
194
- }, [autoplay]);
195
-
196
- const recalcScale = useCallback(() => {
197
- if (!dims || !stageRef.current || dims.w <= 0) return;
198
- setScale(Math.min(1, stageRef.current.clientWidth / dims.w));
199
- }, [dims]);
200
-
201
- useEffect(() => {
202
- recalcScale();
203
- window.addEventListener('resize', recalcScale);
204
- return () => window.removeEventListener('resize', recalcScale);
205
- }, [recalcScale]);
206
-
207
- useEffect(() => {
208
- if (!hasSnapshot) return;
209
- const interval = window.setInterval(() => {
210
- const replayer = replayerRef.current;
211
- if (!replayer) return;
212
- try {
213
- const total = replayer.getMetaData().totalTime;
214
- const current = Math.max(0, Math.min(replayer.getCurrentTime(), total));
215
- setTotalTime(total);
216
- if (scrubbing === null) setCurrentTime(current);
217
- } catch {
218
- // rrweb can briefly be unready while a full snapshot is applied.
219
- }
220
- }, 200);
221
- return () => window.clearInterval(interval);
222
- }, [hasSnapshot, scrubbing]);
223
-
224
- const seek = useCallback(
225
- (value: number) => {
226
- const replayer = replayerRef.current;
227
- if (!replayer) return;
228
- const next = Math.max(0, Math.min(value, totalTime));
229
- if (playing) replayer.play(next);
230
- else replayer.pause(next);
231
- setCurrentTime(next);
232
- },
233
- [playing, totalTime],
234
- );
235
-
236
- const toggle = () => {
237
- const replayer = replayerRef.current;
238
- if (!replayer) return;
239
- if (playing) {
240
- replayer.pause();
241
- setPlaying(false);
242
- return;
243
- }
244
- let offset = currentTime;
245
- try {
246
- if (offset >= replayer.getMetaData().totalTime - 1) offset = 0;
247
- } catch {
248
- offset = 0;
249
- }
250
- replayer.play(offset);
251
- setPlaying(true);
252
- };
253
-
254
- const restart = () => {
255
- replayerRef.current?.play(0);
256
- setCurrentTime(0);
257
- setPlaying(true);
258
- };
259
-
260
- if (!hasSnapshot) {
261
- return (
262
- <div className="replay-preview__empty">
263
- {events.length ? `Captured ${events.length} events, but no full DOM snapshot is available.` : 'Replay data is not available yet.'}
264
- </div>
265
- );
266
- }
85
+ // rrweb (and its stylesheet) load on first use of an event-based source.
86
+ const LazyNativeReplay = lazy(() => import('./NativeReplay'));
267
87
 
88
+ function NativeReplay(props: { events: eventWithTime[]; autoplay?: boolean; loop?: boolean; speeds?: number[] }) {
268
89
  return (
269
- <div className="replay-preview__native">
270
- <div
271
- ref={stageRef}
272
- className="replay-preview__stage"
273
- style={dims ? { height: Math.max(1, Math.round(dims.h * scale)) } : undefined}
274
- >
275
- <div
276
- className="replay-preview__scaler"
277
- style={{ transform: `scale(${scale})`, transformOrigin: 'top left' }}
278
- >
279
- <div ref={playerRef} className="replay-preview__player" aria-hidden="true" />
280
- </div>
281
- </div>
282
- <div className="replay-preview__controls">
283
- <button type="button" onClick={toggle} aria-label={playing ? 'Pause replay' : 'Play replay'}>
284
- {playing ? 'Pause' : 'Play'}
285
- </button>
286
- <button type="button" onClick={restart} aria-label="Restart replay">
287
- Restart
288
- </button>
289
- <input
290
- type="range"
291
- min={0}
292
- max={Math.max(totalTime, 1)}
293
- step={1}
294
- value={scrubbing ?? currentTime}
295
- onChange={(event) => setScrubbing(Number(event.target.value))}
296
- onPointerUp={(event) => {
297
- seek(Number((event.currentTarget as HTMLInputElement).value));
298
- setScrubbing(null);
299
- }}
300
- aria-label="Replay position"
301
- />
302
- <select value={speed} onChange={(event) => setSpeed(Number(event.target.value))} aria-label="Replay speed">
303
- {speeds.map((option) => (
304
- <option key={option} value={option}>
305
- {option}×
306
- </option>
307
- ))}
308
- </select>
309
- </div>
310
- </div>
90
+ <Suspense fallback={<div className="replay-preview__empty">Loading replay…</div>}>
91
+ <LazyNativeReplay {...props} />
92
+ </Suspense>
311
93
  );
312
94
  }
313
95
 
package/src/styles.css CHANGED
@@ -1,4 +1,3 @@
1
- @import "streamdown/styles.css";
2
1
 
3
2
  [data-docstream],
4
3
  .docs-article {