diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 02809177e1..eafa8260d9 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -3669,6 +3669,61 @@ describe("initSandboxRuntimeModular", () => { } }); + it("keeps a root rebound during playback paused on GSAP's ticker", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + let nowMs = 1000; + vi.spyOn(performance, "now").mockImplementation(() => nowMs); + const raf = createManualRaf(); + window.requestAnimationFrame = raf.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame; + document.body.innerHTML = + '
' + + '
'; + const video = document.querySelector("video")!; + vi.spyOn(video, "play").mockResolvedValue(undefined); + vi.spyOn(video, "pause").mockImplementation(() => {}); + const authored = gsap.timeline({ paused: true }); + window.gsap = gsap; + window.__timelines = { main: authored }; + const timelines = vi.spyOn(gsap, "timeline"); + const messages = vi.spyOn(window.parent, "postMessage"); + + try { + initSandboxRuntimeModular(); + vi.advanceTimersByTime(0); + window.__player!.play(); + nowMs = 1250; + raf.step(250); + Object.defineProperty(video, "duration", { value: 12, configurable: true }); + video.dispatchEvent(new Event("loadedmetadata")); + vi.advanceTimersByTime(100); + + expect( + messages.mock.calls.some( + ([message]) => + (message as { code?: string }).code === "timeline_rebind_after_media_metadata", + ), + ).toBe(true); + const rebound = timelines.mock.results.at(-1)?.value as gsap.core.Timeline; + expect(authored.parent).toBe(rebound); + expect(window.__player!.isPlaying()).toBe(true); + expect(rebound.paused()).toBe(true); + const time = rebound.time(); + gsap.updateRoot(gsap.globalTimeline.time() + 0.5); + expect(rebound.time()).toBe(time); + expect(window.__player!.getTime()).toBe(0.25); + nowMs = 1350; + raf.step(100); + expect(rebound.time()).toBeCloseTo(0.35, 5); + } finally { + window.__hfRuntimeTeardown?.(); + for (const result of timelines.mock.results) { + if (result.type === "return") (result.value as gsap.core.Timeline).kill(); + } + authored.kill(); + } + }); + it("sets __renderReady only after timeline is bound, not at __playerReady time", async () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index bbc8f1ac38..f5d2014c25 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2294,21 +2294,17 @@ export function initSandboxRuntimeModular(): void { return false; } const previousTime = Math.max(0, state.currentTime || 0); - const wasPlaying = state.isPlaying; state.capturedTimeline = resolution.timeline; if (typeof state.capturedTimeline.timeScale === "function") { state.capturedTimeline.timeScale(state.playbackRate); } try { // pause guarded separately: a PARTIAL timeline without pause() must not - // abort the seek/play restore below (the catch would swallow them too). + // abort restoring the transport time below (the catch would swallow it too). pauseTimelineIfPossible(state.capturedTimeline); if (typeof state.capturedTimeline.seek === "function") { state.capturedTimeline.seek(previousTime, false); } - if (wasPlaying && typeof state.capturedTimeline.play === "function") { - state.capturedTimeline.play(); - } } catch (err) { // keep runtime resilient even if a timeline implementation throws swallow("runtime.init.site7", err);