From 4dab867f267880b882fa7e48ccf37deda54d2c94 Mon Sep 17 00:00:00 2001 From: krishs0404 Date: Sun, 18 Oct 2026 20:43:44 -0700 Subject: [PATCH 1/3] fix(core): the preview transport keeps ticking in a hidden tab A hidden document runs no animation frames, but its media keeps playing. The transport ticked on requestAnimationFrame only, so syncRuntimeMedia stopped running: a clip was never paused at the end of its window and the next clip never started until the tab was shown again. While document.visibilityState is "hidden", the transport now schedules its tick on a 16 ms timer instead. A visibilitychange listener moves a pending tick to whichever scheduler the new state calls for, and teardown removes it. A paused, hidden transport still parks as before. Refs #5710 (cause 1) Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/runtime/init.ts | 51 +++++++++++++++---- .../core/src/runtime/transportPark.test.ts | 47 +++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c6c8d1a839e..5f501099fe4 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -399,6 +399,7 @@ const MEDIA_URL_ATTRS = new Map([ ]); const SLOW_IDLE_HEARTBEAT_MS = 1000; +const HIDDEN_TRANSPORT_TICK_MS = 16; // One document.getAnimations() per seek and the pause after it, read on first use, shared by all adapters. function pageAnimationsForOnePass(): () => Animation[] { @@ -4783,13 +4784,47 @@ export function initSandboxRuntimeModular(): void { armParkTimer(); }; + // A hidden document runs no animation frames, but its media keeps playing, so the + // transport that ends a clip's window and starts the next one has to tick on a + // timer while hidden. Chromium does not throttle the timers of a page playing audio. + let transportTickTimerId: number | null = null; + const transportTickPending = () => state.transportRafId != null || transportTickTimerId != null; + const requestTransportTick = () => { + if (document.visibilityState === "hidden") { + transportTickTimerId = window.setTimeout(() => { + transportTickTimerId = null; + transportTick(); + }, HIDDEN_TRANSPORT_TICK_MS); + return; + } + state.transportRafId = window.requestAnimationFrame(transportTick); + }; + const cancelTransportTick = () => { + if (state.transportRafId != null) { + window.cancelAnimationFrame(state.transportRafId); + state.transportRafId = null; + } + if (transportTickTimerId != null) { + window.clearTimeout(transportTickTimerId); + transportTickTimerId = null; + } + }; + // A frame requested just before the tab hid never fires; a timer set just before it + // showed again ticks at timer rate for no reason. Move a pending tick across. + const onTransportVisibilityChange = () => { + if (state.tornDown || !transportTickPending()) return; + cancelTransportTick(); + requestTransportTick(); + }; + document.addEventListener("visibilitychange", onTransportVisibilityChange); + const scheduleNextTransportFrame = () => { state.transportRafId = null; if (state.tornDown) return; const woken = transportWakeRequested; transportWakeRequested = false; if (woken || !canParkTransport()) { - state.transportRafId = window.requestAnimationFrame(transportTick); + requestTransportTick(); return; } // Read fresh rather than reusing the tick's own `timingRevision`: the tick @@ -4805,13 +4840,13 @@ export function initSandboxRuntimeModular(): void { // Inside a tick the tail is the only scheduler; setting the flag there is // what stops it parking on work it has just been told about. transportWakeRequested = true; - if (inTransportTick || state.transportRafId != null) return; + if (inTransportTick || transportTickPending()) return; if (transportParkTimerId != null) { window.clearTimeout(transportParkTimerId); transportParkTimerId = null; } transportWakeRequested = false; - state.transportRafId = window.requestAnimationFrame(transportTick); + requestTransportTick(); }; const followedOrLongestRunningAudio = ( @@ -5183,8 +5218,8 @@ export function initSandboxRuntimeModular(): void { installPositionEditsSeekReapply(window as Window & typeof globalThis); - // Start the rAF tick loop - state.transportRafId = window.requestAnimationFrame(transportTick); + // Start the tick loop + requestTransportTick(); postTimeline(); postState(true); @@ -5339,10 +5374,8 @@ export function initSandboxRuntimeModular(): void { const teardown = () => { if (state.tornDown) return; state.tornDown = true; - if (state.transportRafId != null) { - window.cancelAnimationFrame(state.transportRafId); - state.transportRafId = null; - } + cancelTransportTick(); + document.removeEventListener("visibilitychange", onTransportVisibilityChange); if (transportParkTimerId != null) { window.clearTimeout(transportParkTimerId); transportParkTimerId = null; diff --git a/packages/core/src/runtime/transportPark.test.ts b/packages/core/src/runtime/transportPark.test.ts index 71df2620bce..0906841ef8a 100644 --- a/packages/core/src/runtime/transportPark.test.ts +++ b/packages/core/src/runtime/transportPark.test.ts @@ -168,6 +168,7 @@ describe("parked transport loop", () => { afterEach(() => { window.__hfRuntimeTeardown?.(); + delete (document as { visibilityState?: DocumentVisibilityState }).visibilityState; document.body.innerHTML = ""; window.__timelines = {} as Record; delete window.__player; @@ -983,4 +984,50 @@ describe("parked transport loop", () => { expect(window.__player!.isPlaying()).toBe(true); expect(media.isPaused()).toBe(true); }); + + const setVisibility = (state: DocumentVisibilityState) => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + document.dispatchEvent(new Event("visibilitychange")); + }; + + it("keeps the transport ticking on a timer while the document is hidden", () => { + // A hidden document runs no animation frames but its media keeps playing, so a + // transport driven by frames alone never ends a clip at the end of its window. + let nowMs = 1000; + vi.spyOn(performance, "now").mockImplementation(() => nowMs); + mount(``); + const media = stubMediaPlayback(document.getElementById("a1") as HTMLAudioElement, 10); + initSandboxRuntimeModular(); + quiesce(); + + window.__player!.play(); + settle(); + expect(media.isPaused()).toBe(false); + + setVisibility("hidden"); + for (let i = 0; i < 100; i += 1) { + nowMs += 16; + vi.advanceTimersByTime(16); + } + + expect(window.__player!.getTime()).toBeGreaterThan(1); + expect(media.isPaused()).toBe(true); + }); + + it("moves a pending tick between animation frames and a timer as visibility changes", () => { + mount(); + initSandboxRuntimeModular(); + quiesce(); + window.__player!.play(); + expect(raf.pending()).toBe(1); + + setVisibility("hidden"); + expect(raf.pending()).toBe(0); + vi.advanceTimersByTime(16); + expect(raf.pending()).toBe(0); + expect(window.__player!.isPlaying()).toBe(true); + + setVisibility("visible"); + expect(raf.pending()).toBe(1); + }); }); From d2d6583c7b8fab8b682c55ef95764b5e94403149 Mon Sep 17 00:00:00 2001 From: krishs0404 Date: Sun, 18 Oct 2026 21:00:11 -0700 Subject: [PATCH 2/3] docs(core): note the hidden-tab timer clamp for silent media Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/runtime/init.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 5f501099fe4..3f5e1058968 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4786,7 +4786,9 @@ export function initSandboxRuntimeModular(): void { // A hidden document runs no animation frames, but its media keeps playing, so the // transport that ends a clip's window and starts the next one has to tick on a - // timer while hidden. Chromium does not throttle the timers of a page playing audio. + // timer while hidden. Chromium does not throttle the timers of a page playing audio; + // a page playing only muted or silent media is clamped to about one timer a second, + // so a clip there can end up to a second late. let transportTickTimerId: number | null = null; const transportTickPending = () => state.transportRafId != null || transportTickTimerId != null; const requestTransportTick = () => { From 1717b0bf4cae03d832f46742f63f1f304b2ec4e6 Mon Sep 17 00:00:00 2001 From: krishs0404 Date: Sun, 18 Oct 2026 21:01:15 -0700 Subject: [PATCH 3/3] docs(core): shorten the hidden-tab transport comments to hold the comment share Co-Authored-By: Claude Sonnet 5.5 --- packages/core/src/runtime/init.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 3f5e1058968..40a4fe48b9f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4784,11 +4784,8 @@ export function initSandboxRuntimeModular(): void { armParkTimer(); }; - // A hidden document runs no animation frames, but its media keeps playing, so the - // transport that ends a clip's window and starts the next one has to tick on a - // timer while hidden. Chromium does not throttle the timers of a page playing audio; - // a page playing only muted or silent media is clamped to about one timer a second, - // so a clip there can end up to a second late. + // Hidden documents run no animation frames but their media plays on, so tick on a timer. + // Chromium clamps it to ~1 s unless audio is audible: a clip may end up to 1 s late. let transportTickTimerId: number | null = null; const transportTickPending = () => state.transportRafId != null || transportTickTimerId != null; const requestTransportTick = () => { @@ -4811,8 +4808,7 @@ export function initSandboxRuntimeModular(): void { transportTickTimerId = null; } }; - // A frame requested just before the tab hid never fires; a timer set just before it - // showed again ticks at timer rate for no reason. Move a pending tick across. + // A frame requested just before the tab hid never fires: move a pending tick across. const onTransportVisibilityChange = () => { if (state.tornDown || !transportTickPending()) return; cancelTransportTick();