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:
Armaan Jain 2026-07-05 21:18:16 -07:00 committed by GitHub
parent f2276ba39b
commit eba479b7cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 243 additions and 61 deletions

View File

@ -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;
}

View File

@ -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);
});
});

View File

@ -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 });
}