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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-10-03 04:51:45 -07:00
parent 6d25d04ad5
commit d5c5d797e5
6 changed files with 32 additions and 24 deletions
@@ -4,10 +4,9 @@ import { createRuntimePlayer } from "../src/runtime/player";
import type { RuntimeTimelineLike } from "../src/runtime/types"; import type { RuntimeTimelineLike } from "../src/runtime/types";
type Call = { type Call = {
method: "pause" | "seek" | "totalTime" | "render"; method: "pause" | "seek" | "totalTime";
time?: number; time?: number;
suppressEvents?: boolean; suppressEvents?: boolean;
force?: boolean;
}; };
function createTimeline(withTotalTime: boolean): { calls: Call[]; timeline: RuntimeTimelineLike } { 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) => { timeline.totalTime = (timeSeconds: number, suppressEvents?: boolean) => {
calls.push({ method: "totalTime", time: timeSeconds, suppressEvents }); 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 }; return { calls, timeline };
} }
@@ -105,9 +101,12 @@ function testGsapAdapterPreservesTotalTime(): void {
[ [
{ method: "pause" }, { method: "pause" },
{ method: "totalTime", time: seekTime, suppressEvents: false }, { 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",
); );
} }
@@ -63,7 +63,7 @@ describe("gsap adapter on a step", () => {
describe("gsap adapter at a tween's start", () => { 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. // 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", "shows the tween that starts at the seek time, seeking from %s s",
(from) => { (from) => {
const box = document.body.appendChild(document.createElement("div")); 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); 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);
});
}); });
+7 -5
View File
@@ -5,14 +5,16 @@ type GsapAdapterDeps = {
}; };
/** /**
* Re-renders a timeline already at `t`, silently. A forced render at the same time walks the children * Re-renders a timeline already at `t`, silently: just above, just below (not below 0), then `t`. The last move
* forward in authored order and re-renders each one, including a child that starts exactly at `t`. * is forward so same-time steps apply in authored order, and a child starting exactly at `t` crosses its start.
*/ */
export function rerenderGsapTimelineAt( export function rerenderGsapTimelineAt(
timeline: Pick<RuntimeTimelineLike, "render">, timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown },
t: number, t: number,
): void { ): 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 { export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAdapter {
@@ -27,7 +29,7 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
const suppressEvents = ctx.suppressEvents === true; const suppressEvents = ctx.suppressEvents === true;
if (typeof timeline.totalTime === "function") { if (typeof timeline.totalTime === "function") {
timeline.totalTime(safeTime, suppressEvents); timeline.totalTime(safeTime, suppressEvents);
rerenderGsapTimelineAt(timeline, safeTime); rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime);
} else { } else {
timeline.seek(safeTime, suppressEvents); timeline.seek(safeTime, suppressEvents);
} }
+5 -9
View File
@@ -2835,16 +2835,13 @@ describe("initSandboxRuntimeModular", () => {
root.setAttribute("data-height", "1080"); root.setAttribute("data-height", "1080");
document.body.appendChild(root); 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 rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime; const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents }); seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents); return originalTotalTime?.(time, suppressEvents);
}; };
rootTimeline.render = (time: number, suppressEvents?: boolean, force?: boolean) => {
seekCalls.push({ time, suppressEvents, force });
};
window.__timelines = { main: rootTimeline }; window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular(); initSandboxRuntimeModular();
@@ -2854,7 +2851,9 @@ describe("initSandboxRuntimeModular", () => {
expect(seekCalls).toEqual([ expect(seekCalls).toEqual([
{ time: 2, suppressEvents: false }, { 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; seekCalls.length = 0;
@@ -2873,16 +2872,13 @@ describe("initSandboxRuntimeModular", () => {
root.setAttribute("data-height", "1080"); root.setAttribute("data-height", "1080");
document.body.appendChild(root); 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 rootTimeline = createMockTimeline(10);
const originalTotalTime = rootTimeline.totalTime; const originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents }); seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents); return originalTotalTime?.(time, suppressEvents);
}; };
rootTimeline.render = (time: number, suppressEvents?: boolean, force?: boolean) => {
seekCalls.push({ time, suppressEvents, force });
};
Object.assign(rootTimeline, { Object.assign(rootTimeline, {
getChildren: () => [ getChildren: () => [
{ {
+1 -1
View File
@@ -4361,7 +4361,7 @@ export function initSandboxRuntimeModular(): void {
tl.totalTime(tlSeekTime, suppressEvents); tl.totalTime(tlSeekTime, suppressEvents);
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) { if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
// The first seek is the only eventful one; the re-render only refreshes styles. // 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 { } else {
tl.seek(tlSeekTime, suppressEvents); tl.seek(tlSeekTime, suppressEvents);
-1
View File
@@ -315,7 +315,6 @@ export type RuntimeTimelineLike = {
seek: (timeSeconds?: number, suppressEvents?: boolean) => unknown; seek: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown; totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
progress?: (value?: number, suppressEvents?: boolean) => unknown; progress?: (value?: number, suppressEvents?: boolean) => unknown;
render?: (totalTime: number, suppressEvents?: boolean, force?: boolean) => unknown;
time: () => number; time: () => number;
duration: () => number; duration: () => number;
add: (timeline: RuntimeTimelineLike, startAtSeconds: number) => void; add: (timeline: RuntimeTimelineLike, startAtSeconds: number) => void;