diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts
index 23ebd30bc0b..2f6bde9f1ec 100644
--- a/packages/core/src/runtime/init.test.ts
+++ b/packages/core/src/runtime/init.test.ts
@@ -2920,6 +2920,103 @@ 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("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();
+ });
+
+ // 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(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
// 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..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,21 +1947,6 @@ export function initSandboxRuntimeModular(): void {
// clock not yet initialized — duration will be set during TransportClock setup
}
- 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.
- 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);
- }
-
// 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
@@ -3297,6 +3299,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 +3316,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 +3973,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,
@@ -4152,9 +4157,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;
@@ -4370,7 +4376,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) {