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) {