diff --git a/packages/parsers/src/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts index 11b592f9ae..e931a95722 100644 --- a/packages/parsers/src/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -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 = ` diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 315c238c8f..59bc432a85 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -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; } diff --git a/packages/parsers/src/gsapParserAcorn.full.test.ts b/packages/parsers/src/gsapParserAcorn.full.test.ts index eca4213a05..9eeab50737 100644 --- a/packages/parsers/src/gsapParserAcorn.full.test.ts +++ b/packages/parsers/src/gsapParserAcorn.full.test.ts @@ -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 }); diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index 048e0befd5..5e369e14ff 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -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; }