Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/parsers/src/gsapParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,27 @@ describe("stagger/yoyo/repeat round-trip", () => {
});
});

describe("object keyframe per-step duration is not an animatable property", () => {
it("excludes `duration` from %-keyed keyframe properties (segment timing must not become a lane)", () => {
// A %-keyed object keyframe carrying a stray per-step `duration` — the shape
// a buggy array->object conversion produces. `duration` is GSAP segment
// timing, not a property; it must never surface as a keyframe lane or get
// round-tripped as an animatable value (which corrupts the tween on edit).
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#card", { keyframes: { "0%": { x: 0, duration: 0 }, "50%": { x: 100, duration: 6, ease: "power2.out" }, "100%": { x: 200, duration: 6 } } }, 0);
`;
const parsed = parseGsapScript(script);
const kfs = parsed.animations[0].keyframes?.keyframes ?? [];
expect(kfs.length).toBe(3);
for (const kf of kfs) {
expect(kf.properties).not.toHaveProperty("duration"); // the fix
expect(kf.properties).toHaveProperty("x"); // real animatable prop preserved
}
expect(kfs[1]?.ease).toBe("power2.out"); // per-keyframe ease still parsed
});
});

describe("unresolvable value round-trip", () => {
it("preserves unresolvable property values through serialize", () => {
const script = `
Expand Down
6 changes: 6 additions & 0 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,12 @@ function parsePercentageKeyframes(node: AstNode, scope: ScopeBindings): GsapKeyf
for (const [k, v] of Object.entries(record)) {
if (k === "ease" && typeof v === "string") {
kfEase = v;
} else if (k === "duration") {
// `duration` is array-keyframe segment timing, not an animatable
// property. In a %-keyed object keyframe the % key owns timing, so a
// per-step `duration` is neither timing nor a property — skip it, or
// it surfaces as a bogus keyframe lane and corrupts the round-trip.
continue;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
Expand Down
18 changes: 18 additions & 0 deletions packages/parsers/src/gsapParserAcorn.full.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ const REAL_WORLD_SCRIPT = `(function () {
// ── parseGsapScript ───────────────────────────────────────────────────────────

describe("parseGsapScript", () => {
it("excludes per-step `duration` from %-keyed keyframe properties (segment timing is not a lane)", () => {
// A %-keyed object keyframe carrying a stray per-step `duration` — the shape
// a buggy array->object conversion produces. `duration` is GSAP segment
// timing, not an animatable property; it must not surface as a keyframe lane
// or round-trip as a property (which corrupts the tween on the next edit).
const script = `
const tl = gsap.timeline({ paused: true });
tl.to("#card", { keyframes: { "0%": { x: 0, duration: 0 }, "50%": { x: 100, duration: 6, ease: "power2.out" }, "100%": { x: 200, duration: 6 } } }, 0);
`;
const anim = parseGsapScript(script).animations[0]!;
const kfs = expectKeyframesFormat(anim, "percentage", 3);
for (const kf of kfs) {
expect(kf.properties).not.toHaveProperty("duration"); // the fix
expect(kf.properties).toHaveProperty("x"); // real animatable prop preserved
}
expect(kfs[1]!.ease).toBe("power2.out"); // per-keyframe ease still parsed
});

it("parses a basic timeline with .to()", () => {
const script = `
const tl = gsap.timeline({ paused: true });
Expand Down
8 changes: 8 additions & 0 deletions packages/parsers/src/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,14 @@ function parsePercentageKeyframes(
for (const [k, v] of Object.entries(record)) {
if (k === "ease" && typeof v === "string") {
kfEase = v;
} else if (k === "duration") {
// `duration` is array-keyframe SEGMENT TIMING, not an animatable
// property. In a %-keyed object keyframe the % key owns timing, so a
// per-step `duration` is neither timing nor a property here. Skip it
// (parseObjectArrayKeyframes already does) — otherwise it surfaces as
// a bogus "duration" keyframe lane and gets round-tripped as a
// property, corrupting the tween on the next manual edit.
continue;
} else if (typeof v === "number" || typeof v === "string") {
properties[k] = v;
}
Expand Down
Loading