diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/page-client.tsx index d26950bcc..36f8d70e6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/page-client.tsx @@ -19,7 +19,7 @@ import { } from "@/lib/session-replay-streams"; import { cn } from "@/lib/utils"; import { ArrowLeftIcon, ArrowsClockwiseIcon, CheckIcon, CursorClickIcon, FastForwardIcon, FunnelSimpleIcon, GearIcon, LinkIcon, MonitorPlayIcon, PauseIcon, PlayIcon, XIcon } from "@phosphor-icons/react"; -import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; +import { runAsynchronously, runAsynchronouslyWithAlert, wait } from "@hexclave/shared/dist/utils/promises"; import { stringCompare } from "@hexclave/shared/dist/utils/strings"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; @@ -29,6 +29,7 @@ import { useAdminApp, useServerApp } from "../use-admin-app"; import { SessionReplayLimitBanner } from "../analytics/shared"; import { ALLOWED_PLAYER_SPEEDS, + areStatesRenderEquivalent, createInitialState, replayReducer, type ChunkRange, @@ -256,6 +257,63 @@ function getInitialReplaySettings(): ReplaySettings { return { playerSpeed: 1, skipInactivity: true, followActiveTab: false }; } +// Memoized so the markers (potentially thousands of DOM nodes) only re-render +// when the markers themselves change — never from playback-time updates. +const TimelineMarkersLane = React.memo(function TimelineMarkersLane({ + markers, + totalTimeMs, + onSeek, +}: { + markers: TimelineMarker[], + totalTimeMs: number, + onSeek: (timeOffset: number) => void, +}) { + const [hoveredMarkerIndex, setHoveredMarkerIndex] = useState(null); + const hoveredMarker = hoveredMarkerIndex !== null ? markers[hoveredMarkerIndex] ?? null : null; + + return ( +
+ {markers.map((marker, i) => { + const left = totalTimeMs > 0 ? (marker.timeMs / totalTimeMs) * 100 : 0; + if (left < 0 || left > 100) return null; + const isClick = marker.eventType === "$click"; + return ( +
setHoveredMarkerIndex(i)} + onMouseLeave={() => setHoveredMarkerIndex((prev) => prev === i ? null : prev)} + onClick={() => onSeek(marker.timeMs)} + /> + ); + })} + + {/* Custom tooltip */} + {hoveredMarker && (() => { + const left = totalTimeMs > 0 ? (hoveredMarker.timeMs / totalTimeMs) * 100 : 0; + return ( +
+
+
{hoveredMarker.label}
+
{formatTimelineMs(hoveredMarker.timeMs)}
+
+
+ ); + })()} +
+ ); +}); + function Timeline({ getCurrentTimeMs, playerIsPlaying, @@ -275,21 +333,29 @@ function Timeline({ onSpeedChange: (speed: number) => void, markers?: TimelineMarker[], }) { - const [currentTime, setCurrentTime] = useState(0); - const [hoveredMarkerIndex, setHoveredMarkerIndex] = useState(null); const trackRef = useRef(null); + const timeLabelRef = useRef(null); + const progressFillRef = useRef(null); const rafRef = useRef(0); + // The current time is written straight to the DOM every frame instead of + // going through React state — a setState here would re-render the whole + // transport bar (incl. the markers lane) at 60fps. useEffect(() => { function tick() { - setCurrentTime(getCurrentTimeMs()); + const currentTime = getCurrentTimeMs(); + if (timeLabelRef.current) { + timeLabelRef.current.textContent = formatTimelineMs(currentTime); + } + if (progressFillRef.current) { + const progress = totalTimeMs > 0 ? Math.min(currentTime / totalTimeMs, 1) : 0; + progressFillRef.current.style.width = `${progress * 100}%`; + } rafRef.current = requestAnimationFrame(tick); } rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); - }, [getCurrentTimeMs]); - - const progress = totalTimeMs > 0 ? Math.min(currentTime / totalTimeMs, 1) : 0; + }, [getCurrentTimeMs, totalTimeMs]); const handleTrackClick = useCallback((e: React.MouseEvent) => { const el = trackRef.current; @@ -301,7 +367,6 @@ function Timeline({ }, [totalTimeMs, onSeek]); const hasMarkers = (markers?.length ?? 0) > 0; - const hoveredMarker = hoveredMarkerIndex !== null ? markers?.[hoveredMarkerIndex] ?? null : null; return (
@@ -314,52 +379,14 @@ function Timeline({ {playerIsPlaying ? : } - - {formatTimelineMs(currentTime)} + + {formatTimelineMs(0)}
{/* Event markers lane */} - {hasMarkers && ( -
- {markers?.map((marker, i) => { - const left = totalTimeMs > 0 ? (marker.timeMs / totalTimeMs) * 100 : 0; - if (left < 0 || left > 100) return null; - const isClick = marker.eventType === "$click"; - return ( -
setHoveredMarkerIndex(i)} - onMouseLeave={() => setHoveredMarkerIndex((prev) => prev === i ? null : prev)} - onClick={() => onSeek(marker.timeMs)} - /> - ); - })} - - {/* Custom tooltip */} - {hoveredMarker && (() => { - const left = totalTimeMs > 0 ? (hoveredMarker.timeMs / totalTimeMs) * 100 : 0; - return ( -
-
-
{hoveredMarker.label}
-
{formatTimelineMs(hoveredMarker.timeMs)}
-
-
- ); - })()} -
+ {hasMarkers && markers && ( + )} {/* Progress bar track (clickable) */} @@ -370,8 +397,9 @@ function Timeline({ >
@@ -464,9 +492,15 @@ function useReplayMachine(initialSettings: ReplaySettings) { const [, forceRender] = useState(0); const dispatch = useCallback((action: ReplayAction): ReplayEffect[] => { - const { state, effects } = replayReducer(stateRef.current, action); + const previousState = stateRef.current; + const { state, effects } = replayReducer(previousState, action); stateRef.current = state; - forceRender(v => v + 1); + // Only re-render when a render-relevant field changed — the TICK loop + // dispatches several times per second and would otherwise re-render the + // entire page each time (the UI reads playback time imperatively). + if (!areStatesRenderEquivalent(previousState, state)) { + forceRender(v => v + 1); + } return effects; }, []); @@ -953,6 +987,10 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient const activeKey = msRef.current.activeTabKey; for (const [tabKey, r] of replayerByTabRef.current.entries()) { if (tabKey === activeKey) continue; + // Only sync tabs that are actually rendered as mini thumbnails — + // rrweb's pause(offset) is a full synchronous seek, so seeking + // hidden tabs is pure wasted main-thread time. + if (!containerByTabRef.current.get(tabKey)) continue; const stream = msRef.current.streams.find(s => s.tabKey === tabKey); if (!stream) continue; const localOffset = globalOffsetToLocalOffset( @@ -966,6 +1004,10 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient // ignore } } + // Mini-tab visibility derives from the current playback time, which + // no longer triggers React renders on its own — refresh it here, at + // the (throttled) mini-tab sync cadence. + setUiVersion(v => v + 1); break; } case "schedule_buffer_poll": { @@ -1055,12 +1097,24 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient fullStreamsRef.current = []; // Helper: process a batch of chunk_events into the replayer state machine. - function processChunkEvents( + // Yields to the event loop between chunks so that feeding a large batch + // into live replayers (addEvent) doesn't block the main thread while the + // replay is already playing. + async function processChunkEvents( chunkEvents: Array<{ chunkId: string, events: unknown[] }>, allStreams: TabStream[], chunkIdToTabKey: Map, ) { + let isFirstChunk = true; for (const ce of chunkEvents) { + // Only yield while the tab is visible: yielding exists purely to keep + // the visible UI responsive, and browsers clamp setTimeout-backed + // waits in hidden tabs (up to ~1s each), which would make large + // replay loads crawl in the background. + if (!isFirstChunk && !document.hidden) { + await wait(0); + } + isFirstChunk = false; if (msRef.current.generation !== gen) return; const tabKey = chunkIdToTabKey.get(ce.chunkId); @@ -1186,7 +1240,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient } // Process the initial batch of events. - processChunkEvents(initialResponse.chunkEvents, allStreams, chunkIdToTabKey); + await processChunkEvents(initialResponse.chunkEvents, allStreams, chunkIdToTabKey); // Phase 2: Background loading of remaining chunks. const totalChunks = allChunkRows.length; @@ -1198,7 +1252,7 @@ export default function PageClient({ initialReplayId, lockedUserId }: PageClient const batchResponse = await adminApp.getSessionReplayEvents(recordingId, { offset, limit: BACKGROUND_CHUNK_BATCH }); if (msRef.current.generation !== gen) return; - processChunkEvents(batchResponse.chunkEvents, allStreams, chunkIdToTabKey); + await processChunkEvents(batchResponse.chunkEvents, allStreams, chunkIdToTabKey); offset += BACKGROUND_CHUNK_BATCH; } diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.test.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.test.ts index 5b748078a..fac75d1a8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.test.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + areStatesRenderEquivalent, createInitialState, + MINI_TAB_SYNC_INTERVAL_MS, replayReducer, findBestTabAtGlobalOffset, isTabInRangeAtGlobalOffset, @@ -842,14 +844,94 @@ describe("session-replay-machine", () => { expect(s.currentGlobalTimeMsForUi).toBe(3000); }); - it("syncs mini tabs when playing", () => { + it("syncs mini tabs when playing and the sync interval has elapsed", () => { const state = twoTabReadyState({ playbackMode: "playing" }); - const { effects } = dispatch(state, { + const { state: s, effects } = dispatch(state, { type: "TICK", - nowMs: 1000, + nowMs: MINI_TAB_SYNC_INTERVAL_MS, activeReplayerLocalTimeMs: 2000, }); expect(hasEffect(effects, "sync_mini_tabs")).toBe(true); + expect(s.lastMiniTabSyncWallMs).toBe(MINI_TAB_SYNC_INTERVAL_MS); + }); + + it("throttles mini tab syncs within the sync interval", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + const first = dispatch(state, { + type: "TICK", + nowMs: MINI_TAB_SYNC_INTERVAL_MS, + activeReplayerLocalTimeMs: 2000, + }); + expect(hasEffect(first.effects, "sync_mini_tabs")).toBe(true); + + const second = dispatch(first.state, { + type: "TICK", + nowMs: MINI_TAB_SYNC_INTERVAL_MS + 200, + activeReplayerLocalTimeMs: 2200, + }); + expect(hasEffect(second.effects, "sync_mini_tabs")).toBe(false); + + const third = dispatch(second.state, { + type: "TICK", + nowMs: MINI_TAB_SYNC_INTERVAL_MS * 2, + activeReplayerLocalTimeMs: 4000, + }); + expect(hasEffect(third.effects, "sync_mini_tabs")).toBe(true); + }); + + it("resyncs mini tabs on the next tick after a seek", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + lastMiniTabSyncWallMs: MINI_TAB_SYNC_INTERVAL_MS, + }); + const afterSeek = dispatch(state, { + type: "SEEK", + globalOffsetMs: 2000, + nowMs: MINI_TAB_SYNC_INTERVAL_MS + 200, + }); + const { effects } = dispatch(afterSeek.state, { + type: "TICK", + nowMs: MINI_TAB_SYNC_INTERVAL_MS + 400, + activeReplayerLocalTimeMs: 1000, + }); + expect(hasEffect(effects, "sync_mini_tabs")).toBe(true); + }); + + it("resyncs mini tabs after a seek even within the first sync interval of page age", () => { + const state = twoTabReadyState({ + playbackMode: "playing", + lastMiniTabSyncWallMs: 0, + }); + const afterSeek = dispatch(state, { + type: "SEEK", + globalOffsetMs: 2000, + nowMs: 100, + }); + const { effects } = dispatch(afterSeek.state, { + type: "TICK", + nowMs: 300, + activeReplayerLocalTimeMs: 1000, + }); + expect(hasEffect(effects, "sync_mini_tabs")).toBe(true); + }); + }); + + describe("areStatesRenderEquivalent", () => { + it("ignores playback bookkeeping fields", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + const { state: ticked } = dispatch(state, { + type: "TICK", + nowMs: 100, + activeReplayerLocalTimeMs: 1500, + }); + expect(ticked).not.toBe(state); + expect(areStatesRenderEquivalent(state, ticked)).toBe(true); + }); + + it("detects render-relevant changes", () => { + const state = twoTabReadyState({ playbackMode: "playing" }); + const { state: paused } = dispatch(state, { type: "TOGGLE_PLAY_PAUSE", nowMs: 100 }); + expect(areStatesRenderEquivalent(state, paused)).toBe(false); }); }); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.ts b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.ts index 8ae117733..c4f1adcb6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.ts +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/session-replays/session-replay-machine.ts @@ -21,6 +21,14 @@ export const ALLOWED_PLAYER_SPEEDS = new Set([0.5, 1, 2, 4]); * replayer reporting progress before we attempt automatic recovery. */ export const STALL_THRESHOLD_MS = 3000; +/** Minimum wall-clock interval between `sync_mini_tabs` effects while playing. + * Syncing a mini tab is a full rrweb seek (rebuild from the last FullSnapshot + * plus synchronous fast-forward of every event up to the offset), which can + * block the main thread for hundreds of milliseconds on large recordings — + * doing it on every tick froze the page. Mini tabs are small background + * thumbnails, so a coarse sync cadence is enough. */ +export const MINI_TAB_SYNC_INTERVAL_MS = 2000; + export const DEFAULT_REPLAY_SETTINGS: ReplaySettings = { playerSpeed: 1, skipInactivity: true, @@ -101,6 +109,10 @@ export type ReplayState = { * hadn't reported any progress. Used for stall detection. */ playingWithoutProgressSinceMs: number | null, + /** Wall-clock time of the last `sync_mini_tabs` effect, used to throttle + * mini-tab seeks (see MINI_TAB_SYNC_INTERVAL_MS). */ + lastMiniTabSyncWallMs: number, + downloadError: string | null, playerError: string | null, }; @@ -203,6 +215,7 @@ export function createInitialState(settings?: ReplaySettings): ReplayState { gapFastForward: null, prematureFinishRetryLocalMs: null, playingWithoutProgressSinceMs: null, + lastMiniTabSyncWallMs: 0, downloadError: null, playerError: null, }; @@ -341,6 +354,28 @@ function isStaleGeneration(state: ReplayState, generation: number): boolean { return generation !== state.generation; } +/** Fields that only matter for imperative playback bookkeeping — the UI reads + * the current time via `getCurrentTimeMs()` / refs, never from React state. + * Dispatches that change nothing else can skip the React re-render. This is + * what keeps the 200ms TICK loop from re-rendering the whole page. */ +const RENDER_IRRELEVANT_STATE_KEYS: ReadonlySet = new Set([ + "currentGlobalTimeMsForUi", + "pausedAtGlobalMs", + "playingWithoutProgressSinceMs", + "suppressAutoFollowUntilWallMs", + "prematureFinishRetryLocalMs", + "lastMiniTabSyncWallMs", +] satisfies (keyof ReplayState)[]); + +export function areStatesRenderEquivalent(a: ReplayState, b: ReplayState): boolean { + if (a === b) return true; + for (const key of Object.keys(a) as (keyof ReplayState)[]) { + if (RENDER_IRRELEVANT_STATE_KEYS.has(key)) continue; + if (a[key] !== b[key]) return false; + } + return true; +} + // --------------------------------------------------------------------------- // Reducer // --------------------------------------------------------------------------- @@ -903,6 +938,9 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer autoResumeAfterBuffering: true, prematureFinishRetryLocalMs: null, playingWithoutProgressSinceMs: null, + // Reset the throttle here too so mini tabs re-sync on the first + // TICK after buffering resumes rather than waiting out the interval. + lastMiniTabSyncWallMs: action.nowMs - MINI_TAB_SYNC_INTERVAL_MS, playerError: null, }, effects, @@ -925,6 +963,10 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer suppressAutoFollowUntilWallMs: action.nowMs + 400, prematureFinishRetryLocalMs: null, playingWithoutProgressSinceMs: null, + // Reset the throttle so the next TICK re-syncs mini tabs to the new + // position regardless of page age (0 would still throttle during the + // first MINI_TAB_SYNC_INTERVAL_MS after page load). + lastMiniTabSyncWallMs: action.nowMs - MINI_TAB_SYNC_INTERVAL_MS, playerError: null, }, effects, @@ -1030,8 +1072,12 @@ export function replayReducer(state: ReplayState, action: ReplayAction): Reducer let newState = { ...state, currentGlobalTimeMsForUi: globalOffset }; - // Sync mini tabs - if (state.playbackMode === "playing") { + // Sync mini tabs (throttled — a mini-tab sync is a full rrweb seek) + if ( + state.playbackMode === "playing" + && action.nowMs - state.lastMiniTabSyncWallMs >= MINI_TAB_SYNC_INTERVAL_MS + ) { + newState = { ...newState, lastMiniTabSyncWallMs: action.nowMs }; effects.push({ type: "sync_mini_tabs", globalOffsetMs: globalOffset }); }