Skip to content

Commit 6c134c1

Browse files
committed
fix(core): re-cross only the tweens that start at the seek time
The step up past the target (d5c5d79) 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.
1 parent d5c5d79 commit 6c134c1

6 files changed

Lines changed: 97 additions & 10 deletions

File tree

‎packages/core/scripts/test-hyperframe-runtime-seek.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ function testGsapAdapterPreservesTotalTime(): void {
102102
{ method: "pause" },
103103
{ method: "totalTime", time: seekTime, suppressEvents: false },
104104
// Silent re-render arriving from below, so same-time steps apply in authored order
105-
{ method: "totalTime", time: seekTime + 0.001, suppressEvents: true },
106105
{ method: "totalTime", time: seekTime - 0.001, suppressEvents: true },
107106
{ method: "totalTime", time: seekTime, suppressEvents: true },
108107
],

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,57 @@ describe("gsap adapter at a tween's start", () => {
8989
for (const time of [20, 0, 4]) adapter.seek({ time });
9090
expect(gsap.getProperty(box, "x")).toBe(0);
9191
});
92+
93+
it("shows a keyframed tween's start inside a nested timeline, seeking onto it twice", () => {
94+
const box = document.body.appendChild(document.createElement("div"));
95+
const timeline = gsap.timeline({ paused: true });
96+
const scene = gsap.timeline();
97+
scene.from(box, { x: -60, duration: 2, ease: "none" }, 0);
98+
scene.to(box, { keyframes: { "0%": { x: 5 }, "100%": { x: 60 } }, duration: 1 }, 2);
99+
timeline.add(scene, 0.5);
100+
const adapter = createGsapAdapter({
101+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
102+
});
103+
adapter.seek({ time: 2.5 });
104+
adapter.seek({ time: 2.5 });
105+
expect(gsap.getProperty(box, "x")).toBe(5);
106+
});
107+
108+
it("shows a staggered tween's start over an earlier tween's end", () => {
109+
const boxes = [0, 1].map(() => document.body.appendChild(document.createElement("div")));
110+
const timeline = gsap.timeline({ paused: true });
111+
timeline.from(boxes, { x: -60, duration: 2, ease: "none" }, 0);
112+
timeline.fromTo(boxes, { x: 5 }, { x: 60, duration: 1, stagger: 0.1 }, 2);
113+
const adapter = createGsapAdapter({
114+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
115+
});
116+
adapter.seek({ time: 2 });
117+
adapter.seek({ time: 2 });
118+
expect(gsap.getProperty(boxes[0]!, "x")).toBe(5);
119+
});
120+
121+
it("does not start a tween that begins just after the seek time", () => {
122+
const box = document.body.appendChild(document.createElement("div"));
123+
const timeline = gsap.timeline({ paused: true });
124+
timeline.to(box, { x: 100, duration: 10, ease: "none" }, 0);
125+
timeline.to(box, { x: 200, duration: 1, ease: "none", overwrite: "auto" }, 2.0005);
126+
const adapter = createGsapAdapter({
127+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
128+
});
129+
adapter.seek({ time: 2 });
130+
adapter.seek({ time: 1 });
131+
expect(gsap.getProperty(box, "x")).toBe(10);
132+
});
133+
134+
it("keeps a relative repeatRefresh tween on its iteration at a repeat boundary", () => {
135+
const box = document.body.appendChild(document.createElement("div"));
136+
const timeline = gsap.timeline({ paused: true });
137+
timeline.to(box, { x: "+=10", duration: 1, repeat: 1, repeatRefresh: true, ease: "none" }, 0);
138+
const adapter = createGsapAdapter({
139+
getTimeline: () => timeline as unknown as RuntimeTimelineLike,
140+
});
141+
adapter.seek({ time: 1 });
142+
adapter.seek({ time: 1 });
143+
expect(gsap.getProperty(box, "x")).toBe(10);
144+
});
92145
});

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

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
1-
import type { RuntimeDeterministicAdapter, RuntimeTimelineLike } from "../types";
1+
import type {
2+
RuntimeDeterministicAdapter,
3+
RuntimeTimelineChildLike,
4+
RuntimeTimelineLike,
5+
} from "../types";
26

37
type GsapAdapterDeps = {
48
getTimeline: () => RuntimeTimelineLike | null;
59
};
610

711
/**
8-
* Re-renders a timeline already at `t`, silently: just above, just below (not below 0), then `t`. The last move
9-
* is forward so same-time steps apply in authored order, and a child starting exactly at `t` crosses its start.
12+
* Re-renders a timeline already at `t`, silently, from just below (above at 0) so same-time steps apply in authored
13+
* order. That step skips a keyframes or stagger tween already at its start, so each one re-crosses its start alone.
1014
*/
1115
export function rerenderGsapTimelineAt(
12-
timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown },
16+
timeline: {
17+
totalTime: (time: number, suppressEvents?: boolean) => unknown;
18+
getChildren?: RuntimeTimelineLike["getChildren"];
19+
},
1320
t: number,
1421
): void {
15-
timeline.totalTime(t + 0.001, true);
16-
if (t >= 0.001) timeline.totalTime(t - 0.001, true);
22+
timeline.totalTime(t >= 0.001 ? t - 0.001 : t + 0.001, true);
1723
timeline.totalTime(t, true);
24+
recrossTweensStartingAt(timeline, t);
25+
}
26+
27+
function recrossTweensStartingAt(
28+
timeline: Pick<RuntimeTimelineChildLike, "getChildren">,
29+
time: number,
30+
): void {
31+
for (const child of timeline.getChildren?.(false, true, true) ?? []) {
32+
const local = (time - (child.startTime?.() ?? 0)) * (child.timeScale?.() ?? 1);
33+
if (Math.abs(local) < 1e-9 && child.timeline && child.render) {
34+
child.render(0.001, true);
35+
child.render(-0.001, true);
36+
child.render(0, true);
37+
} else if (child.getChildren && local > 0 && local <= (child.totalDuration?.() ?? 0)) {
38+
recrossTweensStartingAt(child, local);
39+
}
40+
}
1841
}
1942

2043
export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter {
@@ -29,7 +52,13 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
2952
const suppressEvents = ctx.suppressEvents === true;
3053
if (typeof timeline.totalTime === "function") {
3154
timeline.totalTime(safeTime, suppressEvents);
32-
rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime);
55+
rerenderGsapTimelineAt(
56+
{
57+
totalTime: timeline.totalTime.bind(timeline),
58+
getChildren: timeline.getChildren?.bind(timeline),
59+
},
60+
safeTime,
61+
);
3362
} else {
3463
timeline.seek(safeTime, suppressEvents);
3564
}

‎packages/core/src/runtime/init.test.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2851,7 +2851,6 @@ describe("initSandboxRuntimeModular", () => {
28512851

28522852
expect(seekCalls).toEqual([
28532853
{ time: 2, suppressEvents: false },
2854-
{ time: 2.001, suppressEvents: true },
28552854
{ time: 1.999, suppressEvents: true },
28562855
{ time: 2, suppressEvents: true },
28572856
]);

‎packages/core/src/runtime/init.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4361,7 +4361,10 @@ export function initSandboxRuntimeModular(): void {
43614361
tl.totalTime(tlSeekTime, suppressEvents);
43624362
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
43634363
// The first seek is the only eventful one; the re-render only refreshes styles.
4364-
rerenderGsapTimelineAt({ totalTime: tl.totalTime.bind(tl) }, tlSeekTime);
4364+
rerenderGsapTimelineAt(
4365+
{ totalTime: tl.totalTime.bind(tl), getChildren: tl.getChildren?.bind(tl) },
4366+
tlSeekTime,
4367+
);
43654368
}
43664369
} else {
43674370
tl.seek(tlSeekTime, suppressEvents);

‎packages/core/src/runtime/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,13 @@ export type RuntimeTimelineChildLike = {
294294
vars?: unknown;
295295
startTime?: () => number;
296296
duration?: () => number;
297+
totalDuration?: () => number;
298+
timeScale?: () => number;
297299
data?: unknown;
298300
parent?: RuntimeTimelineChildLike;
299301
getChildren?: RuntimeTimelineLike["getChildren"];
302+
timeline?: unknown;
303+
render?: (totalTime: number, suppressEvents?: boolean) => unknown;
300304
};
301305

302306
/** A timeline or tween a composition script started, as a scene swap stops it. */

0 commit comments

Comments
 (0)