{/* 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 });
}