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";
type Call = {
method: "pause" | "seek" | "totalTime" | "render";
method: "pause" | "seek" | "totalTime";
time?: number;
suppressEvents?: boolean;
force?: boolean;
};
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) => {
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 };
}
@@ -105,9 +101,12 @@ function testGsapAdapterPreservesTotalTime(): void {
[
{ method: "pause" },
{ 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", () => {
// 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",
(from) => {
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);
},
);
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
* forward in authored order and re-renders each one, including a child that starts exactly at `t`.
* Re-renders a timeline already at `t`, silently: just above, just below (not below 0), then `t`. The last move
* is forward so same-time steps apply in authored order, and a child starting exactly at `t` crosses its start.
*/
export function rerenderGsapTimelineAt(
timeline: Pick<RuntimeTimelineLike, "render">,
timeline: { totalTime: (time: number, suppressEvents?: boolean) => unknown },
t: number,
): 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 {
@@ -27,7 +29,7 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd
const suppressEvents = ctx.suppressEvents === true;
if (typeof timeline.totalTime === "function") {
timeline.totalTime(safeTime, suppressEvents);
rerenderGsapTimelineAt(timeline, safeTime);
rerenderGsapTimelineAt({ totalTime: timeline.totalTime.bind(timeline) }, safeTime);
} else {
timeline.seek(safeTime, suppressEvents);
}
+5 -9
View File
@@ -2835,16 +2835,13 @@ describe("initSandboxRuntimeModular", () => {
root.setAttribute("data-height", "1080");
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 originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};
rootTimeline.render = (time: number, suppressEvents?: boolean, force?: boolean) => {
seekCalls.push({ time, suppressEvents, force });
};
window.__timelines = { main: rootTimeline };
initSandboxRuntimeModular();
@@ -2854,7 +2851,9 @@ describe("initSandboxRuntimeModular", () => {
expect(seekCalls).toEqual([
{ 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;
@@ -2873,16 +2872,13 @@ describe("initSandboxRuntimeModular", () => {
root.setAttribute("data-height", "1080");
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 originalTotalTime = rootTimeline.totalTime;
rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => {
seekCalls.push({ time, suppressEvents });
return originalTotalTime?.(time, suppressEvents);
};
rootTimeline.render = (time: number, suppressEvents?: boolean, force?: boolean) => {
seekCalls.push({ time, suppressEvents, force });
};
Object.assign(rootTimeline, {
getChildren: () => [
{
+1 -1
View File
@@ -4361,7 +4361,7 @@ export function initSandboxRuntimeModular(): void {
tl.totalTime(tlSeekTime, suppressEvents);
if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) {
// 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 {
tl.seek(tlSeekTime, suppressEvents);
-1
View File
@@ -315,7 +315,6 @@ export type RuntimeTimelineLike = {
seek: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
progress?: (value?: number, suppressEvents?: boolean) => unknown;
render?: (totalTime: number, suppressEvents?: boolean, force?: boolean) => unknown;
time: () => number;
duration: () => number;
add: (timeline: RuntimeTimelineLike, startAtSeconds: number) => void;