diff --git a/package.json b/package.json index f0cf05554..5308cecd4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1dd220b99..83d026e2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx b/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx index 2d498d84f..4950ccb54 100644 --- a/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx +++ b/src/app/(main)/websites/[websiteId]/@modal/(.)replays/[sessionId]/page.tsx @@ -7,5 +7,5 @@ export default async function ({ }) { const { websiteId, sessionId } = await params; - return ; + return ; } diff --git a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx index 7127a613d..4b37264ba 100644 --- a/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/ReplayModal.tsx @@ -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) { {({ close }) => ( - + {activeReplayId && ( + + )} )} diff --git a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx index 95ca3e883..960f8e4a0 100644 --- a/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx +++ b/src/app/(main)/websites/[websiteId]/replays/[replayId]/ReplayPlayer.tsx @@ -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(null); + const playerRootRef = useRef(null); const playerRef = useRef(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 ( - +
+
{showUnavailable && }
diff --git a/src/lib/replay.test.ts b/src/lib/replay.test.ts index d875b10e5..db93bfad7 100644 --- a/src/lib/replay.test.ts +++ b/src/lib/replay.test.ts @@ -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); diff --git a/src/lib/replay.ts b/src/lib/replay.ts index 0ad8c96ba..5a8b3c8cb 100644 --- a/src/lib/replay.ts +++ b/src/lib/replay.ts @@ -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; +}