From 14aff01c07ebbcd8d84bca89243955fe04b0135f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 11:01:29 -0700 Subject: [PATCH 1/4] fix(core): renders keep a fromTo's from-only values from the frame it starts on --- .../src/runtime/adapters/gsap.steps.test.ts | 39 +++++++++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 13 +++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index 68ae7a3813b..c849f059022 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -62,6 +62,45 @@ describe("gsap adapter on a step", () => { }); describe("gsap adapter at a tween's start", () => { + // Issue #5122: a render seeks every frame in order; only the from-vars set transformOrigin. + it("keeps a fromTo's from-only values from the frame it starts on, with immediateRender off", () => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + timeline.fromTo( + box, + { scale: 1.5, transformOrigin: "0% 0%" }, + { scale: 1, duration: 0.45, ease: "none", immediateRender: false }, + 0.1, + ); + timeline.set({}, {}, 1); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + const origins = [2, 3, 4, 5].map((frame) => { + adapter.seek({ time: frame / 30 }); + return box.style.transformOrigin; + }); + expect(origins).toEqual(["", "0% 0%", "0% 0%", "0% 0%"]); + expect(gsap.getProperty(box, "scaleX")).toBeCloseTo(1.4259, 3); + }); + + it("still lets a set authored later on the tween's start win over its from-values", () => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + timeline.fromTo( + box, + { opacity: 0, transformOrigin: "0% 0%" }, + { opacity: 1, duration: 0.5, immediateRender: false }, + 0.1, + ); + timeline.set(box, { opacity: 0.3, transformOrigin: "100% 100%" }, 0.1); + timeline.set({}, {}, 1); + createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({ + time: 0.1, + }); + expect([box.style.opacity, box.style.transformOrigin]).toEqual(["0.3", "100% 100%"]); + }); + // 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, 2.5, 3])( "shows the tween that starts at the seek time, seeking from %s s", diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 15d9e46f7c2..b20a9f7792a 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 is first moved across its start alone. + * order. That step skips a keyframed tween at its start and undoes a tween's from-values there, so both are redone. */ export function rerenderGsapTimelineAt( timeline: { @@ -33,7 +33,7 @@ export function rerenderGsapTimelineAt( for (const [child] of skipped) child._ts = 0; try { timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true); - primeKeyframedTweensStartingAt(children, t); + primeTweensStartingAt(children, t); timeline.totalTime(t, true); } finally { for (const [child, timeScale] of skipped) child._ts = timeScale; @@ -96,6 +96,9 @@ type GsapAnimation = { paused: () => boolean; render: (totalTime: number, suppressEvents: boolean) => unknown; vars?: { keyframes?: unknown }; + _startAt?: + | 0 + | { render: (totalTime: number, suppressEvents: boolean, force: boolean) => unknown }; getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[]; }; @@ -113,14 +116,16 @@ const playsForward = (value: unknown): value is GsapAnimation => { ); }; -function primeKeyframedTweensStartingAt(children: unknown[], time: number): void { +function primeTweensStartingAt(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); + } else if (Math.abs(local) < 1e-9 && child._startAt) { + child._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true); } else if (child.getChildren && local > 0 && local <= child.totalDuration()) { - primeKeyframedTweensStartingAt(child.getChildren(false, true, true), local); + primeTweensStartingAt(child.getChildren(false, true, true), local); } } } From 22a52eb87548640cdc3be16d68ad4882290b3191 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 11:11:35 -0700 Subject: [PATCH 2/4] refactor(core): prime a tween at its start in one helper --- packages/core/src/runtime/adapters/gsap.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index b20a9f7792a..86da393cdc3 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -116,15 +116,22 @@ const playsForward = (value: unknown): value is GsapAnimation => { ); }; +function primedAtItsStart(tween: GsapAnimation): boolean { + if (tween.vars?.keyframes) { + tween.render(BELOW_GSAP_TIME_RESOLUTION, true); + tween.render(-BELOW_GSAP_TIME_RESOLUTION, true); + return true; + } + if (!tween._startAt) return false; + tween._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true); + return true; +} + function primeTweensStartingAt(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); - } else if (Math.abs(local) < 1e-9 && child._startAt) { - child._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true); - } else if (child.getChildren && local > 0 && local <= child.totalDuration()) { + if (Math.abs(local) < 1e-9 && primedAtItsStart(child)) continue; + if (child.getChildren && local > 0 && local <= child.totalDuration()) { primeTweensStartingAt(child.getChildren(false, true, true), local); } } From cfbbd599e500ffeb144e220fa62a7ca1f3ec9672 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 11:48:22 -0700 Subject: [PATCH 3/4] fix(core): a keyframed fromTo also keeps its from-only values at its start --- .../src/runtime/adapters/gsap.steps.test.ts | 24 +++++++++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 6 ++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index c849f059022..e79ad368039 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -84,6 +84,30 @@ describe("gsap adapter at a tween's start", () => { expect(gsap.getProperty(box, "scaleX")).toBeCloseTo(1.4259, 3); }); + it("keeps a keyframed fromTo's from-only values over an earlier tween's on the same element", () => { + const box = document.body.appendChild(document.createElement("div")); + const timeline = gsap.timeline({ paused: true }); + const fromTo = (origin: string, to: gsap.TweenVars, at: number) => + timeline.fromTo( + box, + { scale: 1.5, transformOrigin: origin }, + { scale: 1, immediateRender: false, ...to }, + at, + ); + fromTo("0% 0%", { duration: 0.1 }, 0); + fromTo("100% 100%", { duration: 0.1 }, 0.1); + fromTo("0% 0%", { keyframes: { y: [0, 40] }, duration: 0.5 }, 0.2); + timeline.set({}, {}, 1); + const adapter = createGsapAdapter({ + getTimeline: () => timeline as unknown as RuntimeTimelineLike, + }); + const origins = [0, 2 / 30, 0.1, 4 / 30, 0.2, 7 / 30].map((time) => { + adapter.seek({ time }); + return box.style.transformOrigin; + }); + expect(origins.slice(1)).toEqual(["0% 0%", "100% 100%", "100% 100%", "0% 0%", "0% 0%"]); + }); + it("still lets a set authored later on the tween's start win over its from-values", () => { 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 86da393cdc3..9a1eac9a305 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -120,11 +120,9 @@ function primedAtItsStart(tween: GsapAnimation): boolean { if (tween.vars?.keyframes) { tween.render(BELOW_GSAP_TIME_RESOLUTION, true); tween.render(-BELOW_GSAP_TIME_RESOLUTION, true); - return true; } - if (!tween._startAt) return false; - tween._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true); - return true; + if (tween._startAt) tween._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true); + return Boolean(tween.vars?.keyframes || tween._startAt); } function primeTweensStartingAt(children: unknown[], time: number): void { From 1e9f4d174c5a7d7701436037cd2f9570abc6e981 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 6 Oct 2026 11:56:19 -0700 Subject: [PATCH 4/4] fix(core): each staggered target keeps its from-only values from the frame it starts on --- .../src/runtime/adapters/gsap.steps.test.ts | 27 +++++++++++++++++++ packages/core/src/runtime/adapters/gsap.ts | 6 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/adapters/gsap.steps.test.ts b/packages/core/src/runtime/adapters/gsap.steps.test.ts index e79ad368039..297c8f8cc75 100644 --- a/packages/core/src/runtime/adapters/gsap.steps.test.ts +++ b/packages/core/src/runtime/adapters/gsap.steps.test.ts @@ -108,6 +108,33 @@ describe("gsap adapter at a tween's start", () => { expect(origins.slice(1)).toEqual(["0% 0%", "100% 100%", "100% 100%", "0% 0%", "0% 0%"]); }); + it("keeps each staggered target's from-only values from the frame it starts on", () => { + const film = () => { + const boxes = [0, 1, 2].map(() => document.body.appendChild(document.createElement("div"))); + const timeline = gsap.timeline({ paused: true }); + timeline.fromTo( + boxes, + { scale: 1.5, transformOrigin: "0% 0%" }, + { scale: 1, duration: 0.3, stagger: 0.1, ease: "none", immediateRender: false }, + 0.1, + ); + timeline.set({}, {}, 1); + return { boxes, timeline }; + }; + const played = film(); + const seeked = film(); + const adapter = createGsapAdapter({ + getTimeline: () => seeked.timeline as unknown as RuntimeTimelineLike, + }); + for (let frame = 0; frame <= 12; frame++) { + played.timeline.totalTime(frame / 30, false); + adapter.seek({ time: frame / 30 }); + const origins = (film: { boxes: HTMLElement[] }) => + film.boxes.map((box) => box.style.transformOrigin); + expect(origins(seeked), `frame ${frame}`).toEqual(origins(played)); + } + }); + it("still lets a set authored later on the tween's start win over its from-values", () => { 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 9a1eac9a305..7a3695f43de 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -100,6 +100,7 @@ type GsapAnimation = { | 0 | { render: (totalTime: number, suppressEvents: boolean, force: boolean) => unknown }; getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[]; + timeline?: Pick; }; const BELOW_GSAP_TIME_RESOLUTION = 2e-8; @@ -129,8 +130,9 @@ function primeTweensStartingAt(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 && primedAtItsStart(child)) continue; - if (child.getChildren && local > 0 && local <= child.totalDuration()) { - primeTweensStartingAt(child.getChildren(false, true, true), local); + const nested = child.getChildren ? child : child.timeline; + if (nested?.getChildren && local > 0 && local <= child.totalDuration()) { + primeTweensStartingAt(nested.getChildren(false, true, true), local); } } }