Skip to content

Commit 755ab7d

Browse files
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
1 parent dc244d7 commit 755ab7d

2 files changed

Lines changed: 110 additions & 8 deletions

File tree

‎packages/core/src/runtime/adapters/gsap.steps.test.ts‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,96 @@ describe("gsap adapter on a step", () => {
6262
});
6363

6464
describe("gsap adapter at a tween's start", () => {
65+
// Issue #5122: a render seeks every frame in order; only the from-vars set transformOrigin.
66+
it("keeps a fromTo's from-only values from the frame it starts on, with immediateRender off", () => {
67+
const box = document.body.appendChild(document.createElement("div"));
68+
const timeline = gsap.timeline({ paused: true });
69+
timeline.fromTo(
70+
box,
71+
{ scale: 1.5, transformOrigin: "0% 0%" },
72+
{ scale: 1, duration: 0.45, ease: "none", immediateRender: false },
73+
0.1,
74+
);
75+
timeline.set({}, {}, 1);
76+
const adapter = createGsapAdapter({
77+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
78+
});
79+
const origins = [2, 3, 4, 5].map((frame) => {
80+
adapter.seek({ time: frame / 30 });
81+
return box.style.transformOrigin;
82+
});
83+
expect(origins).toEqual(["", "0% 0%", "0% 0%", "0% 0%"]);
84+
expect(gsap.getProperty(box, "scaleX")).toBeCloseTo(1.4259, 3);
85+
});
86+
87+
it("keeps a keyframed fromTo's from-only values over an earlier tween's on the same element", () => {
88+
const box = document.body.appendChild(document.createElement("div"));
89+
const timeline = gsap.timeline({ paused: true });
90+
const fromTo = (origin: string, to: gsap.TweenVars, at: number) =>
91+
timeline.fromTo(
92+
box,
93+
{ scale: 1.5, transformOrigin: origin },
94+
{ scale: 1, immediateRender: false, ...to },
95+
at,
96+
);
97+
fromTo("0% 0%", { duration: 0.1 }, 0);
98+
fromTo("100% 100%", { duration: 0.1 }, 0.1);
99+
fromTo("0% 0%", { keyframes: { y: [0, 40] }, duration: 0.5 }, 0.2);
100+
timeline.set({}, {}, 1);
101+
const adapter = createGsapAdapter({
102+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
103+
});
104+
const origins = [0, 2 / 30, 0.1, 4 / 30, 0.2, 7 / 30].map((time) => {
105+
adapter.seek({ time });
106+
return box.style.transformOrigin;
107+
});
108+
expect(origins.slice(1)).toEqual(["0% 0%", "100% 100%", "100% 100%", "0% 0%", "0% 0%"]);
109+
});
110+
111+
it("keeps each staggered target's from-only values from the frame it starts on", () => {
112+
const film = () => {
113+
const boxes = [0, 1, 2].map(() => document.body.appendChild(document.createElement("div")));
114+
const timeline = gsap.timeline({ paused: true });
115+
timeline.fromTo(
116+
boxes,
117+
{ scale: 1.5, transformOrigin: "0% 0%" },
118+
{ scale: 1, duration: 0.3, stagger: 0.1, ease: "none", immediateRender: false },
119+
0.1,
120+
);
121+
timeline.set({}, {}, 1);
122+
return { boxes, timeline };
123+
};
124+
const played = film();
125+
const seeked = film();
126+
const adapter = createGsapAdapter({
127+
getTimeline: () => seeked.timeline as unknown as RuntimeTimelineLike,
128+
});
129+
for (let frame = 0; frame <= 12; frame++) {
130+
played.timeline.totalTime(frame / 30, false);
131+
adapter.seek({ time: frame / 30 });
132+
const origins = (film: { boxes: HTMLElement[] }) =>
133+
film.boxes.map((box) => box.style.transformOrigin);
134+
expect(origins(seeked), `frame ${frame}`).toEqual(origins(played));
135+
}
136+
});
137+
138+
it("still lets a set authored later on the tween's start win over its from-values", () => {
139+
const box = document.body.appendChild(document.createElement("div"));
140+
const timeline = gsap.timeline({ paused: true });
141+
timeline.fromTo(
142+
box,
143+
{ opacity: 0, transformOrigin: "0% 0%" },
144+
{ opacity: 1, duration: 0.5, immediateRender: false },
145+
0.1,
146+
);
147+
timeline.set(box, { opacity: 0.3, transformOrigin: "100% 100%" }, 0.1);
148+
timeline.set({}, {}, 1);
149+
createGsapAdapter({ getTimeline: () => timeline as unknown as RuntimeTimelineLike }).seek({
150+
time: 0.1,
151+
});
152+
expect([box.style.opacity, box.style.transformOrigin]).toEqual(["0.3", "100% 100%"]);
153+
});
154+
65155
// An edit at 2 s turns the later tween into keyframes; its first keyframe must win over the from() end.
66156
it.each([0, 1, 2, 2.5, 3])(
67157
"shows the tween that starts at the seek time, seeking from %s s",

‎packages/core/src/runtime/adapters/gsap.ts‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ type GsapAdapterDeps = {
66

77
/**
88
* Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored
9-
* order. That step skips a keyframed tween already at its start, so each one is first moved across its start alone.
9+
* order. That step skips a keyframed tween at its start and undoes a tween's from-values there, so both are redone.
1010
*/
1111
export function rerenderGsapTimelineAt(
1212
timeline: {
@@ -33,7 +33,7 @@ export function rerenderGsapTimelineAt(
3333
for (const [child] of skipped) child._ts = 0;
3434
try {
3535
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
36-
primeKeyframedTweensStartingAt(children, t);
36+
primeTweensStartingAt(children, t);
3737
timeline.totalTime(t, true);
3838
} finally {
3939
for (const [child, timeScale] of skipped) child._ts = timeScale;
@@ -96,7 +96,11 @@ type GsapAnimation = {
9696
paused: () => boolean;
9797
render: (totalTime: number, suppressEvents: boolean) => unknown;
9898
vars?: { keyframes?: unknown };
99+
_startAt?:
100+
| 0
101+
| { render: (totalTime: number, suppressEvents: boolean, force: boolean) => unknown };
99102
getChildren?: (nested: boolean, tweens: boolean, timelines: boolean) => unknown[];
103+
timeline?: Pick<GsapAnimation, "getChildren">;
100104
};
101105

102106
const BELOW_GSAP_TIME_RESOLUTION = 2e-8;
@@ -113,14 +117,22 @@ const playsForward = (value: unknown): value is GsapAnimation => {
113117
);
114118
};
115119

116-
function primeKeyframedTweensStartingAt(children: unknown[], time: number): void {
120+
function primedAtItsStart(tween: GsapAnimation): boolean {
121+
if (tween.vars?.keyframes) {
122+
tween.render(BELOW_GSAP_TIME_RESOLUTION, true);
123+
tween.render(-BELOW_GSAP_TIME_RESOLUTION, true);
124+
}
125+
if (tween._startAt) tween._startAt.render(BELOW_GSAP_TIME_RESOLUTION, true, true);
126+
return Boolean(tween.vars?.keyframes || tween._startAt);
127+
}
128+
129+
function primeTweensStartingAt(children: unknown[], time: number): void {
117130
for (const child of children.filter(playsForward)) {
118131
const local = (time - child.startTime()) * child.timeScale();
119-
if (Math.abs(local) < 1e-9 && child.vars?.keyframes) {
120-
child.render(BELOW_GSAP_TIME_RESOLUTION, true);
121-
child.render(-BELOW_GSAP_TIME_RESOLUTION, true);
122-
} else if (child.getChildren && local > 0 && local <= child.totalDuration()) {
123-
primeKeyframedTweensStartingAt(child.getChildren(false, true, true), local);
132+
if (Math.abs(local) < 1e-9 && primedAtItsStart(child)) continue;
133+
const nested = child.getChildren ? child : child.timeline;
134+
if (nested?.getChildren && local > 0 && local <= child.totalDuration()) {
135+
primeTweensStartingAt(nested.getChildren(false, true, true), local);
124136
}
125137
}
126138
}

0 commit comments

Comments
 (0)