diff --git a/packages/core/scripts/test-hyperframe-runtime-seek.ts b/packages/core/scripts/test-hyperframe-runtime-seek.ts index d3de22c561..1dd8f2af14 100644 --- a/packages/core/scripts/test-hyperframe-runtime-seek.ts +++ b/packages/core/scripts/test-hyperframe-runtime-seek.ts @@ -4,10 +4,9 @@ import { createRuntimePlayer } from "../src/runtime/player"; import type { RuntimeTimelineLike } from "../src/runtime/types"; type Call = { - method: "pause" | "seek" | "totalTime" | "render"; + method: "pause" | "seek" | "totalTime"; time?: number; suppressEvents?: boolean; - force?: boolean; }; function createTimeline(withTotalTime: boolean): { calls: Call[]; timeline: RuntimeTimelineLike } { @@ -30,9 +29,6 @@ function createTimeline(withTotalTime: boolean): { calls: Call[]; timeline: Runt timeline.totalTime = (timeSeconds: number, suppressEvents?: boolean) => { calls.push({ method: "totalTime", time: timeSeconds, suppressEvents }); }; - timeline.render = (time: number, suppressEvents?: boolean, force?: boolean) => { - calls.push({ method: "render", time, suppressEvents, force }); - }; } return { calls, timeline }; } @@ -105,9 +101,12 @@ function testGsapAdapterPreservesTotalTime(): void { [ { method: "pause" }, { method: "totalTime", time: seekTime, suppressEvents: false }, - { method: "render", time: seekTime, suppressEvents: true, force: true }, + // Silent re-render arriving from below, so same-time steps apply in authored order + { method: "totalTime", time: seekTime + 0.001, suppressEvents: true }, + { method: "totalTime", time: seekTime - 0.001, suppressEvents: true }, + { method: "totalTime", time: seekTime, suppressEvents: true }, ], - "GSAP adapter should seek via totalTime() then force a silent re-render at the same time", + "GSAP adapter should seek via totalTime() then re-render from just below", ); } diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 7a83e1d43e..05344e668d 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -63,7 +63,7 @@ describe("gsap adapter on a step", () => { describe("gsap adapter at a tween's start", () => { // An edit at 2 s turns the later tween into keyframes; its first keyframe must win over the from() end. - it.each([0, 1, 2.5, 3])( + it.each([0, 1, 2, 2.5, 3])( "shows the tween that starts at the seek time, seeking from %s s", (from) => { const box = document.body.appendChild(document.createElement("div")); @@ -77,4 +77,16 @@ describe("gsap adapter at a tween's start", () => { expect(gsap.getProperty(box, "x")).toBe(5); }, ); + + it("leaves a tween that starts later alone after a seek past the end", () => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + timeline.to(box, { x: -400, duration: 0.4 }, 4.8); + timeline.to(box, { x: 0, duration: 0.4 }, 16); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + for (const time of [20, 0, 4]) adapter.seek({ time }); + expect(gsap.getProperty(box, "x")).toBe(0); + }); }); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index ba8291c39c..5663494e11 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -5,14 +5,16 @@ type GsapAdapterDeps = { }; /** - * Re-renders a timeline already at `t`, silently. A forced render at the same time walks the children - * forward in authored order and re-renders each one, including a child that starts exactly at `t`. + * Re-renders a timeline already at `t`, silently: just above, just below (not below 0), then `t`. The last move + * is forward so same-time steps apply in authored order, and a child starting exactly at `t` crosses its start. */ export function rerenderGsapTimelineAt( - timeline: Pick, + timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown }, t: number, ): void { - timeline.render?.(t, true, true); + timeline.totalTime(t + 0.001, true); + if (t >= 0.001) timeline.totalTime(t - 0.001, true); + timeline.totalTime(t, true); } export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter { @@ -27,7 +29,7 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd const suppressEvents = ctx.suppressEvents === true; if (typeof timeline.totalTime === "function") { timeline.totalTime(safeTime, suppressEvents); - rerenderGsapTimelineAt(timeline, safeTime); + rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime); } else { timeline.seek(safeTime, suppressEvents); } diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 9e06132b42..e8965ae4c5 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2835,16 +2835,13 @@ describe("initSandboxRuntimeModular", () => { root.setAttribute("data-height", "1080"); document.body.appendChild(root); - const seekCalls: Array<{ time: number; suppressEvents?: boolean; force?: boolean }> = []; + const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = []; const rootTimeline = createMockTimeline(10); const originalTotalTime = rootTimeline.totalTime; rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { seekCalls.push({ time, suppressEvents }); return originalTotalTime?.(time, suppressEvents); }; - rootTimeline.render = (time: number, suppressEvents?: boolean, force?: boolean) => { - seekCalls.push({ time, suppressEvents, force }); - }; window.__timelines = { main: rootTimeline }; initSandboxRuntimeModular(); @@ -2854,7 +2851,9 @@ describe("initSandboxRuntimeModular", () => { expect(seekCalls).toEqual([ { time: 2, suppressEvents: false }, - { time: 2, suppressEvents: true, force: true }, + { time: 2.001, suppressEvents: true }, + { time: 1.999, suppressEvents: true }, + { time: 2, suppressEvents: true }, ]); seekCalls.length = 0; @@ -2873,16 +2872,13 @@ describe("initSandboxRuntimeModular", () => { root.setAttribute("data-height", "1080"); document.body.appendChild(root); - const seekCalls: Array<{ time: number; suppressEvents?: boolean; force?: boolean }> = []; + const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = []; const rootTimeline = createMockTimeline(10); const originalTotalTime = rootTimeline.totalTime; rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { seekCalls.push({ time, suppressEvents }); return originalTotalTime?.(time, suppressEvents); }; - rootTimeline.render = (time: number, suppressEvents?: boolean, force?: boolean) => { - seekCalls.push({ time, suppressEvents, force }); - }; Object.assign(rootTimeline, { getChildren: () => [ { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index c91df539f7..8a7f06ca44 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4361,7 +4361,7 @@ export function initSandboxRuntimeModular(): void { tl.totalTime(tlSeekTime, suppressEvents); if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) { // The first seek is the only eventful one; the re-render only refreshes styles. - rerenderGsapTimelineAt(tl, tlSeekTime); + rerenderGsapTimelineAt({ totalTime: tl.totalTime.bind(tl) }, tlSeekTime); } } else { tl.seek(tlSeekTime, suppressEvents); diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index dc834557f3..1c38578372 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -315,7 +315,6 @@ export type RuntimeTimelineLike = { seek: (timeSeconds?: number, suppressEvents?: boolean) => unknown; totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown; progress?: (value?: number, suppressEvents?: boolean) => unknown; - render?: (totalTime: number, suppressEvents?: boolean, force?: boolean) => unknown; time: () => number; duration: () => number; add: (timeline: RuntimeTimelineLike, startAtSeconds: number) => void;