mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Fix session replay playback freezing the page (#1727)
Fixes the session replay player making the page laggy/unresponsive
during playback. Four compounding main-thread bottlenecks:
1. **Mini-tab re-seek every tick (main freeze cause).** Every 200ms TICK
emitted `sync_mini_tabs`, which calls `replayer.pause(offset)` on every
non-active tab. In rrweb v1 that's a full synchronous seek (rebuild from
last FullSnapshot + fast-forward all events), easily 100ms–1s+ of
blocking work per tab, 5×/sec. Now throttled to
`MINI_TAB_SYNC_INTERVAL_MS` (2s) in the state machine, reset on SEEK so
seeks re-sync promptly, and the executor skips tabs that aren't actually
rendered as mini thumbnails.
2. **Full-page re-render every 200ms.** Every dispatch force-rendered
the whole page component. Added `areStatesRenderEquivalent()` —
dispatches that only change playback bookkeeping fields
(`currentGlobalTimeMsForUi`, etc.) skip the React re-render; mini-tab
visibility is refreshed at the throttled sync cadence instead.
3. **Timeline re-rendered at 60fps.** The transport bar rAF loop used
`setState(currentTime)`, re-rendering the whole bar including up to 2000
marker divs every frame. Current time / progress are now written
directly to DOM refs, and the markers lane is extracted into a memoized
`TimelineMarkersLane`.
4. **Synchronous `addEvent` floods.** Background chunk processing now
yields to the event loop between chunks so feeding large batches into
live replayers doesn't block playback.
Added machine tests for the sync throttle, seek re-sync, and render
equivalence (117 passing).
Link to Devin session:
https://app.devin.ai/sessions/7d53fb70217d4ebaa77b9dbac2ba1f54
Requested by: @Developing-Gamer
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes session replay playback freezing by removing main-thread
bottlenecks in the player and timeline. Playback stays smooth and
responsive; background loads finish faster in hidden tabs.
- **Bug Fixes**
- Throttled mini-tab syncing to `MINI_TAB_SYNC_INTERVAL_MS` (2s);
re-syncs on the next tick after seeks and when buffering resumes
(regardless of page age); skips non-rendered tabs to avoid `rrweb` full
seeks; refreshes mini-tab visibility on the throttled cadence.
- Skipped React re-renders for playback bookkeeping via
`areStatesRenderEquivalent`.
- Stopped 60fps timeline re-renders by writing time/progress to DOM refs
and memoizing the markers lane.
- Yield between event chunks only when the tab is visible; skip yields
in hidden tabs to avoid clamped timers and speed up background loads.
- Added tests for sync throttling, post-seek re-sync (immune to page
age), and render-equivalence.
<sup>Written for commit 4abc80c5f7.
Summary will update on new commits.</sup>
<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1727?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **Performance / UX**
* Session replay playback during rapid timeline changes is smoother,
with fewer unnecessary UI refreshes.
* Timeline progress/time updates are more immediate, and event markers
render more efficiently.
* **Bug Fixes**
* Mini-tab syncing is now throttled while playing and correctly re-syncs
after seeking to keep thumbnails accurate.
* Event/chunk loading now yields between batches to reduce main-thread
blocking.
* **Tests**
* Expanded session replay mini-tab sync tests to cover throttling and
post-seek resynchronization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
---------
Co-authored-by: armaan <armaan@stack-auth.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
f2276ba39b
commit
eba479b7cc
@ -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<number | null>(null);
|
||||
const hoveredMarker = hoveredMarkerIndex !== null ? markers[hoveredMarkerIndex] ?? null : null;
|
||||
|
||||
return (
|
||||
<div className="relative h-3.5 mb-0.5">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer",
|
||||
"transition-colors",
|
||||
isClick
|
||||
? "bg-blue-500/70 hover:bg-blue-400"
|
||||
: "bg-emerald-500/70 hover:bg-emerald-400",
|
||||
)}
|
||||
style={{ left: `${left}%`, marginLeft: "-1.5px" }}
|
||||
onMouseEnter={() => 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 (
|
||||
<div
|
||||
className="absolute bottom-full mb-1.5 -translate-x-1/2 pointer-events-none z-50"
|
||||
style={{ left: `${left}%` }}
|
||||
>
|
||||
<div className="max-w-52 whitespace-nowrap rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08]">
|
||||
<div className="truncate">{hoveredMarker.label}</div>
|
||||
<div className="text-[10px] opacity-70">{formatTimelineMs(hoveredMarker.timeMs)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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<number | null>(null);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
const timeLabelRef = useRef<HTMLSpanElement | null>(null);
|
||||
const progressFillRef = useRef<HTMLDivElement | null>(null);
|
||||
const rafRef = useRef<number>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div className={cn(replaysTransportBarClass, hasMarkers ? "py-1.5" : "py-2")}>
|
||||
@ -314,52 +379,14 @@ function Timeline({
|
||||
{playerIsPlaying ? <PauseIcon className="h-4 w-4" /> : <PlayIcon className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<span className="text-xs text-muted-foreground tabular-nums w-10 shrink-0 text-right">
|
||||
{formatTimelineMs(currentTime)}
|
||||
<span ref={timeLabelRef} className="text-xs text-muted-foreground tabular-nums w-10 shrink-0 text-right">
|
||||
{formatTimelineMs(0)}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center">
|
||||
{/* Event markers lane */}
|
||||
{hasMarkers && (
|
||||
<div className="relative h-3.5 mb-0.5">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"absolute bottom-0 w-[3px] h-3 rounded-sm cursor-pointer",
|
||||
"transition-colors",
|
||||
isClick
|
||||
? "bg-blue-500/70 hover:bg-blue-400"
|
||||
: "bg-emerald-500/70 hover:bg-emerald-400",
|
||||
)}
|
||||
style={{ left: `${left}%`, marginLeft: "-1.5px" }}
|
||||
onMouseEnter={() => 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 (
|
||||
<div
|
||||
className="absolute bottom-full mb-1.5 -translate-x-1/2 pointer-events-none z-50"
|
||||
style={{ left: `${left}%` }}
|
||||
>
|
||||
<div className="max-w-52 whitespace-nowrap rounded-md border border-black/[0.08] bg-white px-3 py-1.5 text-xs text-foreground shadow-md ring-1 ring-black/[0.06] dark:border-white/[0.08] dark:bg-primary dark:text-primary-foreground dark:ring-white/[0.08]">
|
||||
<div className="truncate">{hoveredMarker.label}</div>
|
||||
<div className="text-[10px] opacity-70">{formatTimelineMs(hoveredMarker.timeMs)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{hasMarkers && markers && (
|
||||
<TimelineMarkersLane markers={markers} totalTimeMs={totalTimeMs} onSeek={onSeek} />
|
||||
)}
|
||||
|
||||
{/* Progress bar track (clickable) */}
|
||||
@ -370,8 +397,9 @@ function Timeline({
|
||||
>
|
||||
<div className="w-full h-1.5 rounded-full bg-muted relative overflow-hidden">
|
||||
<div
|
||||
ref={progressFillRef}
|
||||
className="absolute inset-y-0 left-0 bg-foreground/60 group-hover:bg-foreground/80 rounded-full transition-colors"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
style={{ width: "0%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -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<ChunkRow>[],
|
||||
chunkIdToTabKey: Map<string, TabKey>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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<keyof ReplayState> = 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 });
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user