Normalize replay events for rrweb-player playback

This commit is contained in:
Mike Cao committed 2026-06-15 20:08:53 -07:00
1 parent a5f78e3e3d
commit 95ece5ba56
7 files changed
+278 -56

No files matched your search

+1 -1
View File
@@ -113,7 +113,7 @@
"redis": "^5.12.1",
"request-ip": "^3.3.0",
"rrweb": "^2.0.1",
"rrweb-player": "^2.0.1",
"rrweb-player": "2.0.0-alpha.20",
"semver": "^7.8.4",
"serialize-error": "^13.0.1",
"thenby": "^1.4.1",
+5 -5
View File
@@ -189,8 +189,8 @@ importers:
specifier: ^2.0.1
version: 2.0.1
rrweb-player:
specifier: ^2.0.1
version: 2.0.1
specifier: 2.0.0-alpha.20
version: 2.0.0-alpha.20
semver:
specifier: ^7.8.4
version: 7.8.4
@@ -5610,8 +5610,8 @@ packages:
rrdom@2.0.1:
resolution: {integrity: sha512-z8GdQWFmVfa8GpcGkAqfBXseb89z0ciTPSBkID0qr0i6KHCye1MdQ4VoXFf0Ejp3ELe/c7AObu4zsbSbMT+hiw==}
rrweb-player@2.0.1:
resolution: {integrity: sha512-v8PVnOy7Q3kXqWriGuzZo4ndGEV06nCMRkH/jSHweJfTLwpLqb2evgMj0S5FwQLsN2X1VM+L2/i7QWGJfgj9zw==}
rrweb-player@2.0.0-alpha.20:
resolution: {integrity: sha512-3ZCv1ksUxuIOn3Vn/eWrwWs9Xy+4KVjISD+q26ZLfisZ3hZ0CPgYG3iC22pmmycIeMS2svOfvf7gPh7jExwpUA==}
rrweb-snapshot@2.0.1:
resolution: {integrity: sha512-H8TA29ct2tpYtr5oaB0N+ksRY9bhEErfAYcKbce4+w8lqEUQChpKZd41HpBJLkp04b5QUfWUg4v6r9Okf8r7vA==}
@@ -11638,7 +11638,7 @@ snapshots:
dependencies:
rrweb-snapshot: 2.0.1
rrweb-player@2.0.1:
rrweb-player@2.0.0-alpha.20:
dependencies:
'@rrweb/packer': 2.0.1
'@rrweb/replay': 2.0.1
@@ -7,5 +7,5 @@ export default async function ({
}) {
const { websiteId, sessionId } = await params;
return <ReplayModal websiteId={websiteId} sessionId={sessionId} />;
return <ReplayModal websiteId={websiteId} replayId={sessionId} />;
}
@@ -2,21 +2,32 @@
import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen';
import { ReplayPlayback } from '@/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayback';
import { useNavigation } from '@/components/hooks';
import { buildPath } from '@/lib/url';
export interface ReplayModalProps extends ModalProps {
websiteId: string;
replayId?: string;
}
export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
export function ReplayModal({ websiteId, replayId, ...props }: ReplayModalProps) {
const {
router,
query: { replay },
searchParams,
updateParams,
} = useNavigation();
const activeReplayId = replayId || replay;
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
router.push(updateParams({ replay: undefined }));
if (replayId) {
const query = Object.fromEntries(searchParams.entries());
delete query.replay;
router.push(buildPath(`/websites/${websiteId}/replays`, query));
} else {
router.push(updateParams({ replay: undefined }));
}
}
};
@@ -24,7 +35,7 @@ export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
<Modal
placement="bottom"
offset="80px"
isOpen={!!replay}
isOpen={!!activeReplayId}
onOpenChange={handleOpenChange}
isDismissable
{...props}
@@ -33,7 +44,9 @@ export function ReplayModal({ websiteId, ...props }: ReplayModalProps) {
<Dialog variant="sheet">
{({ close }) => (
<Column padding="6">
<ReplayPlayback websiteId={websiteId} replayId={replay} onClose={close} />
{activeReplayId && (
<ReplayPlayback websiteId={websiteId} replayId={activeReplayId} onClose={close} />
)}
</Column>
)}
</Dialog>
@@ -1,71 +1,124 @@
'use client';
import { Column } from '@umami/react-zen';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Empty } from '@/components/common/Empty';
import { useMobile } from '@/components/hooks';
import { hasReplayFullSnapshot } from '@/lib/replay';
import { getReplayPlayerEvents, getReplayViewport } from '@/lib/replay';
import 'rrweb-player/dist/style.css';
const DEFAULT_REPLAY_ASPECT_RATIO = 9 / 16;
export function ReplayPlayer({ events }: { events: any[] }) {
const containerRef = useRef<HTMLDivElement>(null);
const playerRootRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<any>(null);
const [loaded, setLoaded] = useState(false);
const [playerError, setPlayerError] = useState(false);
const { isMobile, isPhone } = useMobile();
const canReplay = hasReplayFullSnapshot(events);
const showUnavailable = !events?.length || !canReplay;
const replayEvents = useMemo(() => getReplayPlayerEvents(events), [events]);
const replayViewport = useMemo(() => getReplayViewport(replayEvents), [replayEvents]);
const canReplay = replayEvents.length >= 2;
const showUnavailable = !events?.length || !canReplay || playerError;
const playerWidth = isPhone ? 360 : isMobile ? 640 : 1024;
const playerHeight = isPhone ? 202 : isMobile ? 360 : 576;
const replayAspectRatio = replayViewport
? replayViewport.height / replayViewport.width
: DEFAULT_REPLAY_ASPECT_RATIO;
const playerHeight = Math.round(
playerWidth * Math.min(Math.max(replayAspectRatio, DEFAULT_REPLAY_ASPECT_RATIO), 1.5),
);
const playerOuterHeight = playerHeight + 80;
useEffect(() => {
if (!containerRef.current || !events?.length || !canReplay) return;
let cancelled = false;
const playerRoot = playerRootRef.current;
import('rrweb-player').then(mod => {
const RRWebPlayer = mod.default;
setPlayerError(false);
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
if (playerRef.current) {
playerRef.current.$destroy?.();
playerRef.current = null;
}
playerRef.current = new RRWebPlayer({
target: containerRef.current,
props: {
events: events,
width: playerWidth,
height: playerHeight,
autoPlay: false,
showController: true,
speedOption: [1, 2, 4, 8],
useVirtualDom: false,
showWarning: false,
},
playerRoot?.replaceChildren();
if (!playerRoot || !canReplay) return;
import('rrweb-player')
.then(mod => {
if (cancelled) return;
const RRWebPlayer = mod.default;
playerRoot.replaceChildren();
try {
const player = new RRWebPlayer({
target: playerRoot,
props: {
events: replayEvents,
width: playerWidth,
height: playerHeight,
autoPlay: false,
showController: true,
speedOption: [1, 2, 4, 8],
useVirtualDom: false,
showWarning: false,
},
});
if (cancelled) {
(player as any).$destroy?.();
return;
}
playerRef.current = player;
} catch {
playerRoot.replaceChildren();
if (!cancelled) {
setPlayerError(true);
}
}
})
.catch(() => {
if (!cancelled) {
setPlayerError(true);
}
});
setLoaded(true);
});
return () => {
cancelled = true;
if (playerRef.current) {
playerRef.current.$destroy?.();
playerRef.current = null;
}
playerRoot.replaceChildren();
};
}, [canReplay, events, playerWidth, playerHeight]);
}, [canReplay, replayEvents, playerWidth, playerHeight]);
return (
<Column alignItems="center">
<Column alignItems="center" width="100%">
<div
ref={containerRef}
style={{
width: playerWidth,
minHeight: loaded && canReplay ? undefined : playerHeight,
maxWidth: '100%',
overflow: 'hidden',
width: '100%',
maxWidth: playerWidth,
height: showUnavailable ? playerHeight : playerOuterHeight,
overflowX: showUnavailable ? 'hidden' : 'auto',
overflowY: 'hidden',
borderRadius: '8px',
border: '1px solid var(--base300)',
background: 'var(--base75)',
}}
>
<div
ref={playerRootRef}
style={{
display: showUnavailable ? 'none' : 'block',
width: playerWidth,
height: playerOuterHeight,
}}
/>
{showUnavailable && <Empty message="Replay unavailable." />}
</div>
</Column>
+63 -10
View File
@@ -1,15 +1,78 @@
import { expect, test } from 'vitest';
import {
canReplayEvents,
getReplayEventCount,
getReplayPlayerEvents,
getReplayViewport,
hasReplayableFullSnapshot,
hasReplayFullSnapshot,
hasReplayMeta,
REPLAY_EVENT_FRAGMENT_TYPE,
restoreReplayEventFragments,
} from './replay';
const fullSnapshot = {
type: 2,
timestamp: 1781553116151,
data: {
node: {
type: 0,
id: 1,
childNodes: [{ type: 2, id: 2, tagName: 'html', attributes: {}, childNodes: [] }],
},
initialOffset: { left: 0, top: 0 },
},
};
test('hasReplayFullSnapshot returns true when a full snapshot is present', () => {
expect(hasReplayFullSnapshot([{ type: 4 }, { type: 2 }, { type: 3 }])).toBe(true);
});
test('hasReplayMeta returns true when a meta event is present', () => {
expect(hasReplayMeta([{ type: 4 }, { type: 2 }, { type: 3 }])).toBe(true);
});
test('getReplayViewport returns the recorded viewport dimensions', () => {
expect(getReplayViewport(null)).toBe(null);
expect(getReplayViewport([{ type: 4, data: { width: 0, height: 768 } }])).toBe(null);
expect(getReplayViewport([{ type: 4, data: { width: '1024', height: '1365' } }])).toEqual({
width: 1024,
height: 1365,
});
});
test('hasReplayableFullSnapshot returns true when a usable full snapshot is present', () => {
expect(hasReplayableFullSnapshot([{ type: 4 }, fullSnapshot, { type: 3 }])).toBe(true);
});
test('canReplayEvents requires events that can be normalized with a full snapshot', () => {
expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, fullSnapshot])).toBe(true);
expect(canReplayEvents([fullSnapshot])).toBe(true);
expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, { type: 2 }])).toBe(false);
expect(canReplayEvents([{ type: 4, timestamp: 1781553116150 }, { type: 3 }])).toBe(false);
expect(canReplayEvents([{ ...fullSnapshot, timestamp: undefined }])).toBe(true);
});
test('getReplayPlayerEvents normalizes events for rrweb-player', () => {
expect(getReplayPlayerEvents(null)).toEqual([]);
expect(getReplayPlayerEvents([{ type: 3, timestamp: 1781553116160 }])).toEqual([]);
expect(getReplayPlayerEvents([fullSnapshot])).toEqual([
fullSnapshot,
{ ...fullSnapshot, timestamp: fullSnapshot.timestamp + 1 },
]);
expect(
getReplayPlayerEvents([
{ type: 0, timestamp: '1781553116149' },
{ ...fullSnapshot, timestamp: undefined },
]),
).toEqual([
{ type: 0, timestamp: 1781553116149 },
{ ...fullSnapshot, timestamp: 1781553116150 },
]);
});
test('hasReplayFullSnapshot returns false when only incremental events are present', () => {
expect(hasReplayFullSnapshot([{ type: 4 }, { type: 3 }, { type: 3 }])).toBe(false);
});
@@ -20,16 +83,6 @@ test('hasReplayFullSnapshot handles missing events', () => {
});
test('restoreReplayEventFragments restores fragmented events', () => {
const fullSnapshot = {
type: 2,
timestamp: 1781553116151,
data: {
node: {
type: 0,
childNodes: [{ type: 2, tagName: 'html' }],
},
},
};
const serialized = JSON.stringify(fullSnapshot);
const splitAt = Math.floor(serialized.length / 2);
+103
View File
@@ -1,4 +1,5 @@
export const RRWEB_EVENT_TYPE = {
Meta: 4,
FullSnapshot: 2,
} as const;
@@ -106,3 +107,105 @@ export function hasReplayFullSnapshot(events: any[] | null | undefined) {
Array.isArray(events) && events.some(event => event?.type === RRWEB_EVENT_TYPE.FullSnapshot)
);
}
export function hasReplayableFullSnapshot(events: any[] | null | undefined) {
return (
Array.isArray(events) &&
events.some(event => {
const { data } = event || {};
const { node } = data || {};
return (
event?.type === RRWEB_EVENT_TYPE.FullSnapshot &&
Number.isInteger(node?.type) &&
Array.isArray(node?.childNodes)
);
})
);
}
export function hasReplayMeta(events: any[] | null | undefined) {
return Array.isArray(events) && events.some(event => event?.type === RRWEB_EVENT_TYPE.Meta);
}
function getReplayDimension(value: any) {
const dimension = Number(value);
return Number.isFinite(dimension) && dimension > 0 ? dimension : null;
}
export function getReplayViewport(events: any[] | null | undefined) {
if (!Array.isArray(events)) {
return null;
}
const meta = events.find(event => {
const width = getReplayDimension(event?.data?.width);
const height = getReplayDimension(event?.data?.height);
return event?.type === RRWEB_EVENT_TYPE.Meta && width && height;
});
if (!meta) {
return null;
}
return {
width: getReplayDimension(meta.data.width) as number,
height: getReplayDimension(meta.data.height) as number,
};
}
function getReplayTimestamp(event: any) {
const timestamp = Number(event?.timestamp);
return Number.isFinite(timestamp) ? timestamp : null;
}
export function getReplayPlayerEvents(events: any[] | null | undefined) {
if (!Array.isArray(events)) {
return [];
}
const replayEvents = events.filter(
event => event && typeof event === 'object' && event.type !== undefined && event.type !== null,
);
if (!hasReplayableFullSnapshot(replayEvents)) {
return [];
}
const firstTimestamp =
replayEvents.map(getReplayTimestamp).find(timestamp => timestamp !== null) ?? Date.now();
let lastTimestamp = firstTimestamp - 1;
const normalizedEvents = replayEvents.map(event => {
const timestamp = getReplayTimestamp(event);
if (timestamp !== null) {
lastTimestamp = timestamp;
return typeof event.timestamp === 'number' ? event : { ...event, timestamp };
}
lastTimestamp += 1;
return { ...event, timestamp: lastTimestamp };
});
if (normalizedEvents.length === 1) {
return [
normalizedEvents[0],
{
...normalizedEvents[0],
timestamp: normalizedEvents[0].timestamp + 1,
},
];
}
return normalizedEvents;
}
export function canReplayEvents(events: any[] | null | undefined) {
return getReplayPlayerEvents(events).length >= 2;
}