From 74d76986eaa87d87eb4850adc798dbaa0099d7f1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 11:41:34 -0700 Subject: [PATCH 1/4] fix(core): a torn-down runtime no longer re-seeks the next one's timeline The deferred readiness pass scheduled at start-up was never cleared on teardown. When a runtime started again in the same window, the old pass still ran, bound the new root timeline and seeked it to the old playhead. It is now cleared with the runtime's other timers. --- packages/core/src/runtime/init.test.ts | 19 +++++++++++++++++++ packages/core/src/runtime/init.ts | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 23ebd30bc0b..46b23b3277f 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2920,6 +2920,25 @@ describe("initSandboxRuntimeModular", () => { expect(fired).toHaveBeenCalledTimes(1); }); + it("leaves the next runtime's timeline alone after a runtime is torn down", () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + document.body.innerHTML = `
`; + window.__timelines = { main: createMockTimeline(30) }; + initSandboxRuntimeModular(); + window.__player?.seek(16.2); + window.__hfForceTimelineRebind?.(); + window.__hfRuntimeTeardown?.(); + + const next = createMockTimeline(30); + window.__timelines = { main: next }; + initSandboxRuntimeModular(); + window.__player?.seek(0.25); + vi.runOnlyPendingTimers(); + + expect(next.time()).toBeCloseTo(0.25, 1); + vi.useRealTimers(); + }); + it("shows pip video at global start time even when host composition starts late", () => { // Regression: resolveStartForElement used to add the host composition's start on top of // the video's own data-start, causing double-offset. A pip video with data-start="45.40" diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a2f8251afe3..7cdc06fdbb6 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4152,9 +4152,10 @@ export function initSandboxRuntimeModular(): void { // timelines in __timelines haven't executed yet (they run in the browser's next // microtask). Defer a rebinding attempt to catch them. if (externalCompositionsReady) { - setTimeout(() => { + const deferredRebindTimerId = window.setTimeout(() => { maybePublishRenderReady(); }, 0); + registerRuntimeCleanup(() => window.clearTimeout(deferredRebindTimerId)); } let transportTickCount = 0; let inTransportTick = false; From 07415c8400e588ad7460ab4b4cd3e9c752577e3e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 11:41:37 -0700 Subject: [PATCH 2/4] fix(core): a call on the playhead fires once when a readiness pass runs Start-up and readiness passes re-seek every adapter, including the GSAP adapter, which seeks the root timeline the root seek already renders. Its silent re-render re-armed a tl.call at the playhead, so the next forward seek fired it again. Which adapters the root seek leaves out is now one predicate used by all three adapter seeks. --- packages/core/src/runtime/init.test.ts | 25 +++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 9 ++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 46b23b3277f..2827dfa5a22 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2939,6 +2939,31 @@ describe("initSandboxRuntimeModular", () => { vi.useRealTimers(); }); + it("fires a call on the playhead once when a readiness pass runs between seeks", () => { + vi.useFakeTimers(); + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const fired = vi.fn(); + const main = gsap.timeline({ paused: true }).to({ x: 0 }, { x: 1, duration: 10 }); + main.call(fired, [], 2); + window.__timelines = { main }; + initSandboxRuntimeModular(); + + window.__player?.renderSeek(2); + vi.runOnlyPendingTimers(); + window.__player?.renderSeek(3); + + expect(fired).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + it("shows pip video at global start time even when host composition starts late", () => { // Regression: resolveStartForElement used to add the host composition's start on top of // the video's own data-start, causing double-offset. A pip video with data-start="45.40" diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7cdc06fdbb6..152594e0653 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3297,6 +3297,9 @@ export function initSandboxRuntimeModular(): void { postState(true); }; + const isSeekedByRoot = (adapter: RuntimeDeterministicAdapter): boolean => + adapter.name === "gsap" && Boolean(state.capturedTimeline); + const runAdapters = ( method: "discover" | "pause" | "play", timeSeconds = 0, @@ -3311,7 +3314,7 @@ export function initSandboxRuntimeModular(): void { // keep runtime resilient against adapter-specific failures swallow("runtime.init.site8", err); } - if (method === "discover") { + if (method === "discover" && !isSeekedByRoot(adapter)) { try { adapter.seek({ time: timeSeconds, suppressEvents: true }); } catch (err) { @@ -3968,7 +3971,7 @@ export function initSandboxRuntimeModular(): void { onStatePost: postState, onDeterministicSeek: (timeSeconds, options) => { for (const adapter of state.deterministicAdapters) { - if (adapter.name === "gsap" && state.capturedTimeline) continue; + if (isSeekedByRoot(adapter)) continue; try { adapter.seek({ time: Number(timeSeconds) || 0, @@ -4371,7 +4374,7 @@ export function initSandboxRuntimeModular(): void { seekStandaloneRegisteredTimelines(t, opts); const pageAnimations = pageAnimationsForOnePass(); for (const adapter of state.deterministicAdapters) { - if (adapter.name === "gsap" && tl) continue; + if (isSeekedByRoot(adapter)) continue; try { adapter.seek({ time: t, suppressEvents, pageAnimations }); } catch (err) { From e899e035a1e0ba2267a226cd44b46258b3fcc363 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 18:07:24 -0700 Subject: [PATCH 3/4] fix(core): a late seek-only root timeline binds at the playhead Binding restored the playhead only through totalTime(); a root timeline with seek() alone stayed at 0 once the discovery pass stopped re-seeking GSAP. Fall back to seek(), as every other runtime seek path does. --- packages/core/src/runtime/init.test.ts | 38 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 12 ++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 2827dfa5a22..b8c656f7f92 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2964,6 +2964,44 @@ describe("initSandboxRuntimeModular", () => { vi.useRealTimers(); }); + it("shows the playhead's frame when a seek-only root binds late during a paused drag", () => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + document.body.innerHTML = ` +
+
+
+
+ `; + window.__hfTimelinesBuilding = true; + initSandboxRuntimeModular(); + window.__player?.seek(5); + + const tl = gsap + .timeline({ paused: true }) + .to("#sibling", { x: 100, duration: 10, ease: "none" }); + const seekOnly: RuntimeTimelineLike = { + play: () => tl.play(), + pause: () => tl.pause(), + seek: (time, suppressEvents) => tl.seek(time ?? 0, suppressEvents), + time: () => tl.time(), + duration: () => tl.duration(), + add: (child, at) => tl.add(child as unknown as gsap.core.Timeline, at), + paused: (paused) => tl.paused(paused), + set: (target, vars, at) => tl.set(target, vars, at), + }; + window.__timelines = { main: seekOnly }; + window.__hfTimelinesBuilding = false; + window.dispatchEvent(new CustomEvent("hf-timelines-built")); + raf.step(16); + raf.step(16); + + expect(tl.time()).toBeCloseTo(5); + expect(gsap.getProperty("#sibling", "x")).toBeCloseTo(50); + }); + it("shows pip video at global start time even when host composition starts late", () => { // Regression: resolveStartForElement used to add the host composition's start on top of // the video's own data-start, causing double-offset. A pip video with data-start="45.40" diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 152594e0653..0a42f4a846c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1930,20 +1930,22 @@ export function initSandboxRuntimeModular(): void { // clock not yet initialized — duration will be set during TransportClock setup } + // Seek to the prior playhead (state.currentTime) so a rebind after a user + // scrub or soft-reload restore doesn't snap back to 0. + const seekTime = Math.max(0, state.currentTime || 0); if (typeof state.capturedTimeline.totalTime === "function") { // GSAP won't render tl.set() at position 0 when the paused timeline // starts there — play/pause/seek/totalTime are all no-ops at the // creation position. Force the set to render by cycling progress past - // 0 (when the timeline implements it), then seek to the prior playhead - // (state.currentTime) so a rebind after a user scrub or soft-reload - // restore doesn't snap back to 0. + // 0 (when the timeline implements it). if (typeof state.capturedTimeline.progress === "function") { state.capturedTimeline.progress(0.0001, true); } - const seekTime = Math.max(0, state.currentTime || 0); state.capturedTimeline.totalTime(seekTime, false); - pauseTimelineIfPossible(state.capturedTimeline); + } else { + state.capturedTimeline.seek(seekTime, false); } + pauseTimelineIfPossible(state.capturedTimeline); // GSAP bakes the CSS `translate` into style.transform on seek. // The Studio seek wrapper (installStudioManualEditSeekReapply) calls From 861f955c598439c0b84cfb14e2176d09f2aeeb9e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 18:24:18 -0700 Subject: [PATCH 4/4] fix(core): a root timeline binds at the playhead before its duration is known The bind restore sat inside the positive-duration branch, so a root with no resolved duration yet stayed at 0 now that discovery no longer re-seeks a bound GSAP root. --- packages/core/src/runtime/init.test.ts | 87 +++++++++++++++----------- packages/core/src/runtime/init.ts | 34 +++++----- 2 files changed, 68 insertions(+), 53 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index b8c656f7f92..2f6bde9f1ec 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2964,43 +2964,58 @@ describe("initSandboxRuntimeModular", () => { vi.useRealTimers(); }); - it("shows the playhead's frame when a seek-only root binds late during a paused drag", () => { - const raf = createManualRaf(); - vi.spyOn(performance, "now").mockImplementation(() => raf.now()); - window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame; - window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; - document.body.innerHTML = ` -
-
-
-
- `; - window.__hfTimelinesBuilding = true; - initSandboxRuntimeModular(); - window.__player?.seek(5); - - const tl = gsap - .timeline({ paused: true }) - .to("#sibling", { x: 100, duration: 10, ease: "none" }); - const seekOnly: RuntimeTimelineLike = { - play: () => tl.play(), - pause: () => tl.pause(), - seek: (time, suppressEvents) => tl.seek(time ?? 0, suppressEvents), - time: () => tl.time(), - duration: () => tl.duration(), - add: (child, at) => tl.add(child as unknown as gsap.core.Timeline, at), - paused: (paused) => tl.paused(paused), - set: (target, vars, at) => tl.set(target, vars, at), - }; - window.__timelines = { main: seekOnly }; - window.__hfTimelinesBuilding = false; - window.dispatchEvent(new CustomEvent("hf-timelines-built")); - raf.step(16); - raf.step(16); + // Discovery no longer re-seeks a bound GSAP root, so binding alone must land it on the playhead. + it.each([ + { root: "seek-only", declared: ' data-duration="10"', reported: 10 }, + { root: "seek-only", declared: "", reported: 0 }, + { root: "totalTime", declared: "", reported: 0 }, + ])( + "shows the playhead's frame when a $root root (duration $reported) binds late during a paused drag", + ({ root, declared, reported }) => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + window.requestAnimationFrame = + raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + document.body.innerHTML = ` +
+
+
+
+ `; + window.__hfTimelinesBuilding = true; + initSandboxRuntimeModular(); + window.__player?.seek(5); + + const fired = vi.fn(); + const tl = gsap + .timeline({ paused: true }) + .to("#sibling", { x: 100, duration: 10, ease: "none" }) + .call(fired, [], 2); + const facade: RuntimeTimelineLike = { + play: () => tl.play(), + pause: () => tl.pause(), + seek: (time, suppressEvents) => tl.seek(time ?? 0, suppressEvents), + time: () => tl.time(), + duration: () => reported, + add: (child, at) => tl.add(child as unknown as gsap.core.Timeline, at), + paused: (paused) => tl.paused(paused), + set: (target, vars, at) => tl.set(target, vars, at), + ...(root === "totalTime" && { + totalTime: (time, suppressEvents) => tl.totalTime(time ?? 0, suppressEvents), + }), + }; + window.__timelines = { main: facade }; + window.__hfTimelinesBuilding = false; + window.dispatchEvent(new CustomEvent("hf-timelines-built")); + raf.step(16); + raf.step(16); - expect(tl.time()).toBeCloseTo(5); - expect(gsap.getProperty("#sibling", "x")).toBeCloseTo(50); - }); + expect(tl.time()).toBeCloseTo(5); + expect(gsap.getProperty("#sibling", "x")).toBeCloseTo(50); + expect(fired).toHaveBeenCalledTimes(1); + }, + ); it("shows pip video at global start time even when host composition starts late", () => { // Regression: resolveStartForElement used to add the host composition's start on top of diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 0a42f4a846c..758fb59949f 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1923,6 +1923,23 @@ export function initSandboxRuntimeModular(): void { pauseTimelineIfPossible(state.capturedTimeline); } } + // Seek to the prior playhead (state.currentTime) so a rebind after a user + // scrub or soft-reload restore doesn't snap back to 0, whether or not the + // duration is known yet: discovery no longer re-seeks a bound root. + const seekTime = Math.max(0, state.currentTime || 0); + if (typeof state.capturedTimeline.totalTime === "function") { + // GSAP won't render tl.set() at position 0 when the paused timeline + // starts there — play/pause/seek/totalTime are all no-ops at the + // creation position. Force the set to render by cycling progress past + // 0 (when the timeline implements it). + if (typeof state.capturedTimeline.progress === "function") { + state.capturedTimeline.progress(0.0001, true); + } + state.capturedTimeline.totalTime(seekTime, false); + } else { + state.capturedTimeline.seek(seekTime, false); + } + pauseTimelineIfPossible(state.capturedTimeline); if (boundDuration > 0) { try { clock.setDuration(boundDuration); @@ -1930,23 +1947,6 @@ export function initSandboxRuntimeModular(): void { // clock not yet initialized — duration will be set during TransportClock setup } - // Seek to the prior playhead (state.currentTime) so a rebind after a user - // scrub or soft-reload restore doesn't snap back to 0. - const seekTime = Math.max(0, state.currentTime || 0); - if (typeof state.capturedTimeline.totalTime === "function") { - // GSAP won't render tl.set() at position 0 when the paused timeline - // starts there — play/pause/seek/totalTime are all no-ops at the - // creation position. Force the set to render by cycling progress past - // 0 (when the timeline implements it). - if (typeof state.capturedTimeline.progress === "function") { - state.capturedTimeline.progress(0.0001, true); - } - state.capturedTimeline.totalTime(seekTime, false); - } else { - state.capturedTimeline.seek(seekTime, false); - } - pauseTimelineIfPossible(state.capturedTimeline); - // GSAP bakes the CSS `translate` into style.transform on seek. // The Studio seek wrapper (installStudioManualEditSeekReapply) calls // reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook