fix(core): the preview transport keeps ticking in a hidden tab (#5872)

* 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 <noreply@anthropic.com>

* docs(core): note the hidden-tab timer clamp for silent media

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs(core): shorten the hidden-tab transport comments to hold the comment share

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Krish Sharma
2026-10-19 13:32:52 +00:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent cea0bad793
commit b168d0b413
2 changed files with 87 additions and 9 deletions
+40 -9
View File
@@ -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,45 @@ export function initSandboxRuntimeModular(): void {
armParkTimer();
};
// 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 = () => {
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: 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 +4838,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 +5216,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 +5372,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;
@@ -168,6 +168,7 @@ describe("parked transport loop", () => {
afterEach(() => {
window.__hfRuntimeTeardown?.();
delete (document as { visibilityState?: DocumentVisibilityState }).visibilityState;
document.body.innerHTML = "";
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
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(`<audio id="a1" data-start="0" data-duration="1"></audio>`);
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);
});
});