fix(core): renders keep a fromTo's from-only values from the frame it starts on (#5125)

* fix(core): renders keep a fromTo's from-only values from the frame it starts on

* refactor(core): prime a tween at its start in one helper

* fix(core): a keyframed fromTo also keeps its from-only values at its start

* fix(core): each staggered target keeps its from-only values from the frame it starts on
This commit is contained in:
Miguel Ángel
2026-10-06 20:22:57 +00:00
committed by GitHub
parent dc244d765c
commit 755ab7d165
2 changed files with 110 additions and 8 deletions
@@ -62,6 +62,96 @@ 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("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("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 });
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",
+20 -8
View File
@@ -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,7 +96,11 @@ 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[];
timeline?: Pick<GsapAnimation, "getChildren">;
};
const BELOW_GSAP_TIME_RESOLUTION = 2e-8;
@@ -113,14 +117,22 @@ const playsForward = (value: unknown): value is GsapAnimation => {
);
};
function primeKeyframedTweensStartingAt(children: unknown[], time: number): void {
function primedAtItsStart(tween: GsapAnimation): boolean {
if (tween.vars?.keyframes) {
tween.render(BELOW_GSAP_TIME_RESOLUTION, true);
tween.render(-BELOW_GSAP_TIME_RESOLUTION, 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 {
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 (child.getChildren && local > 0 && local <= child.totalDuration()) {
primeKeyframedTweensStartingAt(child.getChildren(false, true, true), local);
if (Math.abs(local) < 1e-9 && primedAtItsStart(child)) continue;
const nested = child.getChildren ? child : child.timeline;
if (nested?.getChildren && local > 0 && local <= child.totalDuration()) {
primeTweensStartingAt(nested.getChildren(false, true, true), local);
}
}
}