From 834d746ec8c65930863af8e285844733acf8c7cf Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 03:20:15 -0700 Subject: [PATCH 1/8] fix(core): a seek onto a tween's start shows that tween, not the earlier one's end The seek re-render stepped to just below the target and back. A child starting exactly at the target sits at local time 0 both before and after that step, so GSAP skips it going forward and an earlier tween on the same property keeps the frame (fromto edits at the playhead read the from() end, 35-50 px off). One forced silent render at the target walks every child forward in authored order instead, so same-time steps still apply in order. --- .../core/scripts/test-hyperframe-runtime-seek.ts | 12 +++++++----- .../core/src/runtime/adapters/gsap.steps.test.ts | 13 +++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 14 +++++--------- packages/core/src/runtime/init.test.ts | 13 +++++++++---- packages/core/src/runtime/init.ts | 2 +- packages/core/src/runtime/types.ts | 1 + 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/core/scripts/test-hyperframe-runtime-seek.ts b/packages/core/scripts/test-hyperframe-runtime-seek.ts index 3be369a5331..d3de22c5617 100644 --- a/packages/core/scripts/test-hyperframe-runtime-seek.ts +++ b/packages/core/scripts/test-hyperframe-runtime-seek.ts @@ -4,9 +4,10 @@ import { createRuntimePlayer } from "../src/runtime/player"; import type { RuntimeTimelineLike } from "../src/runtime/types"; type Call = { - method: "pause" | "seek" | "totalTime"; + method: "pause" | "seek" | "totalTime" | "render"; time?: number; suppressEvents?: boolean; + force?: boolean; }; function createTimeline(withTotalTime: boolean): { calls: Call[]; timeline: RuntimeTimelineLike } { @@ -29,6 +30,9 @@ 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 }; } @@ -101,11 +105,9 @@ function testGsapAdapterPreservesTotalTime(): void { [ { method: "pause" }, { method: "totalTime", time: seekTime, suppressEvents: false }, - // 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, suppressEvents: true }, + { method: "render", time: seekTime, suppressEvents: true, force: true }, ], - "GSAP adapter should seek via totalTime() then re-render from just below", + "GSAP adapter should seek via totalTime() then force a silent re-render at the same time", ); } diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index aba6ae136b6..1eefbd34047 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -60,3 +60,16 @@ describe("gsap adapter on a step", () => { expect(gsap.getProperty(box, "x")).toBe(10); }); }); + +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])("shows the tween that starts at the seek time, seeking from %s s", (from) => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0); + timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2); + timeline.totalTime(from, true); + createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({ time: 2 }); + expect(gsap.getProperty(box, "x")).toBe(5); + }); +}); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index ddaa087be59..f024e471c67 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -5,15 +5,11 @@ type GsapAdapterDeps = { }; /** - * Re-renders a timeline already moved to `t`, silently. It arrives from just below, since GSAP - * applies same-time zero-duration steps in authored order only going forward; at 0 it comes from above. + * 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`. */ -export function rerenderGsapTimelineAt( - timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown }, - t: number, -): void { - timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true); - timeline.totalTime(t, true); +export function rerenderGsapTimelineAt(timeline: Pick, t: number): void { + timeline.render?.(t, true, true); } export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter { @@ -28,7 +24,7 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd const suppressEvents = ctx.suppressEvents === true; if (typeof timeline.totalTime === "function") { timeline.totalTime(safeTime, suppressEvents); - rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime); + rerenderGsapTimelineAt(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 da698302fdb..9e06132b420 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2835,13 +2835,16 @@ describe("initSandboxRuntimeModular", () => { root.setAttribute("data-height", "1080"); document.body.appendChild(root); - const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = []; + const seekCalls: Array<{ time: number; suppressEvents?: boolean; force?: 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(); @@ -2851,8 +2854,7 @@ describe("initSandboxRuntimeModular", () => { expect(seekCalls).toEqual([ { time: 2, suppressEvents: false }, - { time: 1.999, suppressEvents: true }, - { time: 2, suppressEvents: true }, + { time: 2, suppressEvents: true, force: true }, ]); seekCalls.length = 0; @@ -2871,13 +2873,16 @@ describe("initSandboxRuntimeModular", () => { root.setAttribute("data-height", "1080"); document.body.appendChild(root); - const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = []; + const seekCalls: Array<{ time: number; suppressEvents?: boolean; force?: 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 8a7f06ca445..c91df539f77 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({ totalTime: tl.totalTime.bind(tl) }, tlSeekTime); + rerenderGsapTimelineAt(tl, tlSeekTime); } } else { tl.seek(tlSeekTime, suppressEvents); diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 1c38578372d..dc834557f3e 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -315,6 +315,7 @@ 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; From 6d25d04ad550926fb8dbd9fa4dd091ea50cf4fed Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 03:49:47 -0700 Subject: [PATCH 2/8] style(core): format the seek re-render change --- .../src/runtime/adapters/gsap.steps.test.ts | 23 +++++++++++-------- packages/core/src/runtime/adapters/gsap.ts | 5 +++- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 1eefbd34047..7a83e1d43ec 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -63,13 +63,18 @@ 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])("shows the tween that starts at the seek time, seeking from %s s", (from) => { - const box = document.body.appendChild(document.createElement("div")); - const timeline = gsap.timeline({ paused: true }); - timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0); - timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2); - timeline.totalTime(from, true); - createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({ time: 2 }); - expect(gsap.getProperty(box, "x")).toBe(5); - }); + it.each([0, 1, 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")); + const timeline = gsap.timeline({ paused: true }); + timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0); + timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2); + timeline.totalTime(from, true); + createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({ + time: 2, + }); + expect(gsap.getProperty(box, "x")).toBe(5); + }, + ); }); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index f024e471c67..ba8291c39c8 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -8,7 +8,10 @@ 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`. */ -export function rerenderGsapTimelineAt(timeline: Pick, t: number): void { +export function rerenderGsapTimelineAt( + timeline: Pick, + t: number, +): void { timeline.render?.(t, true, true); } From d5c5d797e5d9c51ad34d4e5b66bd6b6ffd4a582b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 04:51:45 -0700 Subject: [PATCH 3/8] fix(core): re-render a seek from just above too, not with a forced render A forced render at the seek time redrew every tween GSAP had ever rendered, including ones that start later, at their recorded start values: after a seek past the end, a later tween's start showed early (style-5-prod, hdr-regression). The re-render now steps silently just above the target, then just below, then to it. The step up makes a child that starts exactly at the target cross its start on the way down, so it renders; tweens that have not started are left as the seek put them, and the last move is still forward, so same-time steps keep their authored order. --- .../core/scripts/test-hyperframe-runtime-seek.ts | 13 ++++++------- .../core/src/runtime/adapters/gsap.steps.test.ts | 14 +++++++++++++- packages/core/src/runtime/adapters/gsap.ts | 12 +++++++----- packages/core/src/runtime/init.test.ts | 14 +++++--------- packages/core/src/runtime/init.ts | 2 +- packages/core/src/runtime/types.ts | 1 - 6 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/core/scripts/test-hyperframe-runtime-seek.ts b/packages/core/scripts/test-hyperframe-runtime-seek.ts index d3de22c5617..1dd8f2af14c 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 7a83e1d43ec..05344e668d5 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 ba8291c39c8..5663494e111 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 9e06132b420..e8965ae4c5e 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 c91df539f77..8a7f06ca445 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 dc834557f3e..1c38578372d 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; From 6c134c117a45e902fc02682ec7743340a34c1458 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 08:25:16 -0700 Subject: [PATCH 4/8] fix(core): re-cross only the tweens that start at the seek time The step up past the target (d5c5d797) initialised tweens starting within the next millisecond, so an overwrite: "auto" tween killed the one before it, and it crossed a repeatRefresh boundary sitting on the target. The re-render is back to #4911's step from just below. That step skips a keyframes or stagger tween already at its start (GSAP passes no start crossing into the tween's own timeline), so after it each such tween that starts exactly at the target, at any nesting depth, re-crosses its own start alone. No other child moves. --- .../scripts/test-hyperframe-runtime-seek.ts | 1 - .../src/runtime/adapters/gsap.steps.test.ts | 53 +++++++++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 43 ++++++++++++--- packages/core/src/runtime/init.test.ts | 1 - packages/core/src/runtime/init.ts | 5 +- packages/core/src/runtime/types.ts | 4 ++ 6 files changed, 97 insertions(+), 10 deletions(-) diff --git a/packages/core/scripts/test-hyperframe-runtime-seek.ts b/packages/core/scripts/test-hyperframe-runtime-seek.ts index 1dd8f2af14c..3be369a5331 100644 --- a/packages/core/scripts/test-hyperframe-runtime-seek.ts +++ b/packages/core/scripts/test-hyperframe-runtime-seek.ts @@ -102,7 +102,6 @@ function testGsapAdapterPreservesTotalTime(): void { { method: "pause" }, { method: "totalTime", time: seekTime, suppressEvents: false }, // 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 }, ], diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 05344e668d5..585ce50c401 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -89,4 +89,57 @@ describe("gsap adapter at a tween's start", () => { for (const time of [20, 0, 4]) adapter.seek({ time }); expect(gsap.getProperty(box, "x")).toBe(0); }); + + it("shows a keyframed tween's start inside a nested timeline, seeking onto it twice", () => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + const scene = gsap.timeline(); + scene.from(box, { x: -60, duration: 2, ease: "none" }, 0); + scene.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2); + timeline.add(scene, 0.5); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 2.5 }); + adapter.seek({ time: 2.5 }); + expect(gsap.getProperty(box, "x")).toBe(5); + }); + + it("shows a staggered tween's start over an earlier tween's end", () => { + const boxes = [0, 1].map(() => document.body.appendChild(document.createElement("div"))); + const timeline = gsap.timeline({ paused: true }); + timeline.from(boxes, { x: -60, duration: 2, ease: "none" }, 0); + timeline.fromTo(boxes, { x: 5 }, { x: 60, duration: 1, stagger: 0.1 }, 2); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 2 }); + adapter.seek({ time: 2 }); + expect(gsap.getProperty(boxes[0]!, "x")).toBe(5); + }); + + it("does not start a tween that begins just after the seek time", () => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + timeline.to(box, { x: 100, duration: 10, ease: "none" }, 0); + timeline.to(box, { x: 200, duration: 1, ease: "none", overwrite: "auto" }, 2.0005); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 2 }); + adapter.seek({ time: 1 }); + expect(gsap.getProperty(box, "x")).toBe(10); + }); + + it("keeps a relative repeatRefresh tween on its iteration at a repeat boundary", () => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + timeline.to(box, { x: "+=10", duration: 1, repeat: 1, repeatRefresh: true, ease: "none" }, 0); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + adapter.seek({ time: 1 }); + adapter.seek({ time: 1 }); + expect(gsap.getProperty(box, "x")).toBe(10); + }); }); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 5663494e111..f77bea1dce5 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -1,20 +1,43 @@ -import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "../types"; +import type { + RuntimeDeterministicAdapter, + RuntimeTimelineChildLike, + RuntimeTimelineLike, +} from "../types"; type GsapAdapterDeps = { getTimeline: () => RuntimeTimelineLike | null; }; /** - * 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. + * Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored + * order. That step skips a keyframes or stagger tween already at its start, so each one re-crosses its start alone. */ export function rerenderGsapTimelineAt( - timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown }, + timeline: { + totalTime: (time: number, suppressEvents?: boolean) => unknown; + getChildren?: RuntimeTimelineLike["getChildren"]; + }, t: number, ): void { - timeline.totalTime(t + 0.001, true); - if (t >= 0.001) timeline.totalTime(t - 0.001, true); + timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true); timeline.totalTime(t, true); + recrossTweensStartingAt(timeline, t); +} + +function recrossTweensStartingAt( + timeline: Pick, + time: number, +): void { + for (const child of timeline.getChildren?.(false, true, true) ?? []) { + const local = (time - (child.startTime?.() ?? 0)) * (child.timeScale?.() ?? 1); + if (Math.abs(local) < 1e-9 && child.timeline && child.render) { + child.render(0.001, true); + child.render(-0.001, true); + child.render(0, true); + } else if (child.getChildren && local > 0 && local <= (child.totalDuration?.() ?? 0)) { + recrossTweensStartingAt(child, local); + } + } } export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter { @@ -29,7 +52,13 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd const suppressEvents = ctx.suppressEvents === true; if (typeof timeline.totalTime === "function") { timeline.totalTime(safeTime, suppressEvents); - rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime); + rerenderGsapTimelineAt( + { + totalTime: timeline.totalTime.bind(timeline), + getChildren: timeline.getChildren?.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 e8965ae4c5e..da698302fdb 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -2851,7 +2851,6 @@ describe("initSandboxRuntimeModular", () => { expect(seekCalls).toEqual([ { time: 2, suppressEvents: false }, - { time: 2.001, suppressEvents: true }, { time: 1.999, suppressEvents: true }, { time: 2, suppressEvents: true }, ]); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 8a7f06ca445..6effb9d32db 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4361,7 +4361,10 @@ 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({ totalTime: tl.totalTime.bind(tl) }, tlSeekTime); + rerenderGsapTimelineAt( + { totalTime: tl.totalTime.bind(tl), getChildren: tl.getChildren?.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 1c38578372d..52625eb04ff 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -294,9 +294,13 @@ export type RuntimeTimelineChildLike = { vars?: unknown; startTime?: () => number; duration?: () => number; + totalDuration?: () => number; + timeScale?: () => number; data?: unknown; parent?: RuntimeTimelineChildLike; getChildren?: RuntimeTimelineLike["getChildren"]; + timeline?: unknown; + render?: (totalTime: number, suppressEvents?: boolean) => unknown; }; /** A timeline or tween a composition script started, as a scene swap stops it. */ From 8636562e50385956db2afdd8f3c2cd3a54050604 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 08:36:15 -0700 Subject: [PATCH 5/8] refactor(core): keep the GSAP members the re-cross reads local to the adapter The shared child type gained members that clash with RuntimeTimelineLike in the runtime typecheck. The adapter now types them locally and skips any child that is not a GSAP animation, which also keeps the walk simple. --- packages/core/src/runtime/adapters/gsap.ts | 37 +++++++++++++--------- packages/core/src/runtime/types.ts | 4 --- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index f77bea1dce5..02b129e83e9 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -1,8 +1,4 @@ -import type { - RuntimeDeterministicAdapter, - RuntimeTimelineChildLike, - RuntimeTimelineLike, -} from "../types"; +import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "../types"; type GsapAdapterDeps = { getTimeline: () => RuntimeTimelineLike | null; @@ -21,21 +17,32 @@ export function rerenderGsapTimelineAt( ): void { timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true); timeline.totalTime(t, true); - recrossTweensStartingAt(timeline, t); + recrossTweensStartingAt(timeline.getChildren?.(false, true, true) ?? [], t); } -function recrossTweensStartingAt( - timeline: Pick, - time: number, -): void { - for (const child of timeline.getChildren?.(false, true, true) ?? []) { - const local = (time - (child.startTime?.() ?? 0)) * (child.timeScale?.() ?? 1); - if (Math.abs(local) < 1e-9 && child.timeline && child.render) { +type GsapAnimation = { + startTime: () => number; + timeScale: () => number; + totalDuration: () => number; + render: (totalTime: number, suppressEvents: boolean) => unknown; + timeline?: unknown; + getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[]; +}; + +const isGsapAnimation = (value: unknown): value is GsapAnimation => + typeof (value as GsapAnimation | null)?.render === "function" && + typeof (value as GsapAnimation).startTime === "function" && + typeof (value as GsapAnimation).timeScale === "function"; + +function recrossTweensStartingAt(children: unknown[], time: number): void { + for (const child of children.filter(isGsapAnimation)) { + const local = (time - child.startTime()) * child.timeScale(); + if (Math.abs(local) < 1e-9 && child.timeline) { child.render(0.001, true); child.render(-0.001, true); child.render(0, true); - } else if (child.getChildren && local > 0 && local <= (child.totalDuration?.() ?? 0)) { - recrossTweensStartingAt(child, local); + } else if (child.getChildren && local > 0 && local <= child.totalDuration()) { + recrossTweensStartingAt(child.getChildren(false, true, true), local); } } } diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 52625eb04ff..1c38578372d 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -294,13 +294,9 @@ export type RuntimeTimelineChildLike = { vars?: unknown; startTime?: () => number; duration?: () => number; - totalDuration?: () => number; - timeScale?: () => number; data?: unknown; parent?: RuntimeTimelineChildLike; getChildren?: RuntimeTimelineLike["getChildren"]; - timeline?: unknown; - render?: (totalTime: number, suppressEvents?: boolean) => unknown; }; /** A timeline or tween a composition script started, as a scene swap stops it. */ From d279a5cac34dcf2002507a53aa1d1f28e2a22507 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 08:45:29 -0700 Subject: [PATCH 6/8] fix(core): re-cross only forward-playing tweens, by a microsecond GSAP skips paused and reversed children when it renders a timeline, so the re-cross does too: it read a reversed keyframed tween's start instead of its end and reset a paused one. The step past the start shrinks from 1 ms to 1 us, so it cannot start a stagger's next target or cross a short tween's repeat. --- .../src/runtime/adapters/gsap.steps.test.ts | 57 +++++++++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 24 +++++--- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 585ce50c401..1cc12e0b819 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -142,4 +142,61 @@ describe("gsap adapter at a tween's start", () => { adapter.seek({ time: 1 }); expect(gsap.getProperty(box, "x")).toBe(10); }); + + it.each([ + { + shape: "a stagger whose next target starts 0.5 ms later with overwrite auto", + build: (timeline: gsap.core.Timeline, o: { x: number }) => { + timeline.to(o, { x: 100, duration: 10, ease: "none" }, 0); + timeline.to([{ x: 0 }, o], { x: 200, duration: 1, stagger: 0.0005, overwrite: "auto" }, 2); + }, + seeks: [2, 1], + x: 10, + }, + { + shape: "a reversed keyframed tween", + build: (timeline: gsap.core.Timeline, o: { x: number }) => { + const tween = gsap.to(o, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }); + timeline.add(tween, 1); + tween.timeScale(-1); + }, + seeks: [1], + x: 60, + }, + { + shape: "a paused keyframed tween", + build: (timeline: gsap.core.Timeline, o: { x: number }) => { + const tween = gsap.to(o, { + keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, + duration: 1, + ease: "none", + }); + timeline.add(tween, 1); + tween.totalTime(0.5).pause(); + }, + seeks: [1, 1], + x: 32.5, + }, + { + shape: "a short repeatRefresh keyframed tween", + build: (timeline: gsap.core.Timeline, o: { x: number }) => { + const keyframes = [ + { x: "+=10", duration: 0.0002 }, + { x: "+=10", duration: 0.0002 }, + ]; + timeline.to(o, { keyframes, repeat: 2, repeatRefresh: true, ease: "none" }, 2); + }, + seeks: [2, 2], + x: 0, + }, + ])("leaves $shape as GSAP renders it at its start", ({ build, seeks, x }) => { + const o = { x: 0 }; + const timeline = gsap.timeline({ paused: true }); + build(timeline, o); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + for (const time of seeks) adapter.seek({ time }); + expect(o.x).toBeCloseTo(x, 6); + }); }); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 02b129e83e9..64d984b3493 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -24,22 +24,32 @@ type GsapAnimation = { startTime: () => number; timeScale: () => number; totalDuration: () => number; + paused: () => boolean; render: (totalTime: number, suppressEvents: boolean) => unknown; timeline?: unknown; getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[]; }; -const isGsapAnimation = (value: unknown): value is GsapAnimation => - typeof (value as GsapAnimation | null)?.render === "function" && - typeof (value as GsapAnimation).startTime === "function" && - typeof (value as GsapAnimation).timeScale === "function"; +const STEP_PAST_START = 1e-6; + +const playsForward = (value: unknown): value is GsapAnimation => { + const animation = value as GsapAnimation | null; + return ( + typeof animation?.render === "function" && + typeof animation.startTime === "function" && + typeof animation.paused === "function" && + typeof animation.timeScale === "function" && + animation.timeScale() > 0 && + !animation.paused() + ); +}; function recrossTweensStartingAt(children: unknown[], time: number): void { - for (const child of children.filter(isGsapAnimation)) { + for (const child of children.filter(playsForward)) { const local = (time - child.startTime()) * child.timeScale(); if (Math.abs(local) < 1e-9 && child.timeline) { - child.render(0.001, true); - child.render(-0.001, true); + child.render(STEP_PAST_START, true); + child.render(-STEP_PAST_START, true); child.render(0, true); } else if (child.getChildren && local > 0 && local <= child.totalDuration()) { recrossTweensStartingAt(child.getChildren(false, true, true), local); From ca6697a852f79153292077fd6fab4c32d581db97 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 09:05:44 -0700 Subject: [PATCH 7/8] fix(core): re-cross only keyframed tweens, by less than GSAP's time step A 1 us step still reached a stagger target 0.5 us in and a 0.4 us repeat, both sizes GSAP can represent. The step is now 2e-8: above GSAP's zero threshold, so the tween leaves its start, but below its 1e-7 time rounding, so the tween's own timeline does not move. Only keyframed tweens are re-crossed; a stagger is left as main renders it. --- .../src/runtime/adapters/gsap.steps.test.ts | 23 ++++--------------- packages/core/src/runtime/adapters/gsap.ts | 12 +++++----- 2 files changed, 11 insertions(+), 24 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 1cc12e0b819..c2769cc2e12 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -105,19 +105,6 @@ describe("gsap adapter at a tween's start", () => { expect(gsap.getProperty(box, "x")).toBe(5); }); - it("shows a staggered tween's start over an earlier tween's end", () => { - const boxes = [0, 1].map(() => document.body.appendChild(document.createElement("div"))); - const timeline = gsap.timeline({ paused: true }); - timeline.from(boxes, { x: -60, duration: 2, ease: "none" }, 0); - timeline.fromTo(boxes, { x: 5 }, { x: 60, duration: 1, stagger: 0.1 }, 2); - const adapter = createGsapAdapter({ - getTimeline: () => timeline as unknown as RuntimeTimelineLike, - }); - adapter.seek({ time: 2 }); - adapter.seek({ time: 2 }); - expect(gsap.getProperty(boxes[0]!, "x")).toBe(5); - }); - it("does not start a tween that begins just after the seek time", () => { const box = document.body.appendChild(document.createElement("div")); const timeline = gsap.timeline({ paused: true }); @@ -145,10 +132,10 @@ describe("gsap adapter at a tween's start", () => { it.each([ { - shape: "a stagger whose next target starts 0.5 ms later with overwrite auto", + shape: "a stagger whose next target starts 0.1 us later with overwrite auto", build: (timeline: gsap.core.Timeline, o: { x: number }) => { timeline.to(o, { x: 100, duration: 10, ease: "none" }, 0); - timeline.to([{ x: 0 }, o], { x: 200, duration: 1, stagger: 0.0005, overwrite: "auto" }, 2); + timeline.to([{ x: 0 }, o], { x: 200, duration: 1, stagger: 1e-7, overwrite: "auto" }, 2); }, seeks: [2, 1], x: 10, @@ -178,11 +165,11 @@ describe("gsap adapter at a tween's start", () => { x: 32.5, }, { - shape: "a short repeatRefresh keyframed tween", + shape: "a 0.4 us repeatRefresh keyframed tween", build: (timeline: gsap.core.Timeline, o: { x: number }) => { const keyframes = [ - { x: "+=10", duration: 0.0002 }, - { x: "+=10", duration: 0.0002 }, + { x: "+=10", duration: 2e-7 }, + { x: "+=10", duration: 2e-7 }, ]; timeline.to(o, { keyframes, repeat: 2, repeatRefresh: true, ease: "none" }, 2); }, diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 64d984b3493..f767f224518 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -6,7 +6,7 @@ type GsapAdapterDeps = { /** * Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored - * order. That step skips a keyframes or stagger tween already at its start, so each one re-crosses its start alone. + * order. That step skips a keyframed tween already at its start, so each one re-crosses its own start alone. */ export function rerenderGsapTimelineAt( timeline: { @@ -26,11 +26,11 @@ type GsapAnimation = { totalDuration: () => number; paused: () => boolean; render: (totalTime: number, suppressEvents: boolean) => unknown; - timeline?: unknown; + vars?: { keyframes?: unknown }; getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[]; }; -const STEP_PAST_START = 1e-6; +const BELOW_GSAP_TIME_RESOLUTION = 2e-8; const playsForward = (value: unknown): value is GsapAnimation => { const animation = value as GsapAnimation | null; @@ -47,9 +47,9 @@ const playsForward = (value: unknown): value is GsapAnimation => { function recrossTweensStartingAt(children: unknown[], time: number): void { for (const child of children.filter(playsForward)) { const local = (time - child.startTime()) * child.timeScale(); - if (Math.abs(local) < 1e-9 && child.timeline) { - child.render(STEP_PAST_START, true); - child.render(-STEP_PAST_START, true); + if (Math.abs(local) < 1e-9 && child.vars?.keyframes) { + child.render(BELOW_GSAP_TIME_RESOLUTION, true); + child.render(-BELOW_GSAP_TIME_RESOLUTION, true); child.render(0, true); } else if (child.getChildren && local > 0 && local <= child.totalDuration()) { recrossTweensStartingAt(child.getChildren(false, true, true), local); From f9185a7bf3d3b68a6f280d3e413d88cf3a688245 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 3 Oct 2026 09:51:10 -0700 Subject: [PATCH 8/8] fix(core): move keyframed tweens across their start before the forward step Re-crossing a keyframed tween after the timeline's forward render made it win over a later same-time sibling (a set after it at the same time showed the keyframe, not the set). The keyframed tweens that start at the target are now moved across their start during the step from below, and the timeline's own forward step renders them with every sibling, in authored order. --- .../src/runtime/adapters/gsap.steps.test.ts | 27 +++++++++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 9 +++---- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index c2769cc2e12..778c10f2284 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -90,6 +90,33 @@ describe("gsap adapter at a tween's start", () => { expect(gsap.getProperty(box, "x")).toBe(0); }); + it.each([ + { order: "keyframes, then a set", x: 42 }, + { order: "a set, then keyframes", x: 5 }, + ])("keeps the authored order of $order at the seek time", ({ order, x }) => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + timeline.from(box, { x: -60, duration: 2, ease: "none" }, 0); + const keyframes = () => + timeline.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2); + const set = () => timeline.set(box, { x: 42 }, 2); + if (order.startsWith("keyframes")) { + keyframes(); + set(); + } else { + set(); + keyframes(); + } + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + for (const from of [0, 1, 2, 2.5, 3]) { + timeline.totalTime(from, true); + adapter.seek({ time: 2 }); + expect(gsap.getProperty(box, "x")).toBe(x); + } + }); + it("shows a keyframed tween's start inside a nested timeline, seeking onto it twice", () => { const box = document.body.appendChild(document.createElement("div")); const timeline = gsap.timeline({ paused: true }); diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index f767f224518..3af49a71e48 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -6,7 +6,7 @@ type GsapAdapterDeps = { /** * Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored - * order. That step skips a keyframed tween already at its start, so each one re-crosses its own start alone. + * order. That step skips a keyframed tween already at its start, so each one is first moved across its start alone. */ export function rerenderGsapTimelineAt( timeline: { @@ -16,8 +16,8 @@ export function rerenderGsapTimelineAt( t: number, ): void { timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true); + primeKeyframedTweensStartingAt(timeline.getChildren?.(false, true, true) ?? [], t); timeline.totalTime(t, true); - recrossTweensStartingAt(timeline.getChildren?.(false, true, true) ?? [], t); } type GsapAnimation = { @@ -44,15 +44,14 @@ const playsForward = (value: unknown): value is GsapAnimation => { ); }; -function recrossTweensStartingAt(children: unknown[], time: number): void { +function primeKeyframedTweensStartingAt(children: unknown[], time: number): void { for (const child of children.filter(playsForward)) { const local = (time - child.startTime()) * child.timeScale(); if (Math.abs(local) < 1e-9 && child.vars?.keyframes) { child.render(BELOW_GSAP_TIME_RESOLUTION, true); child.render(-BELOW_GSAP_TIME_RESOLUTION, true); - child.render(0, true); } else if (child.getChildren && local > 0 && local <= child.totalDuration()) { - recrossTweensStartingAt(child.getChildren(false, true, true), local); + primeKeyframedTweensStartingAt(child.getChildren(false, true, true), local); } } }