Version: 0.7.21
Summary
When a canvas move/resize lands an add-keyframe mutation on top of an existing keyframe that already has an ease, the serializer writes the ease key twice in the same keyframe object. It's bounded to 2 (never 3+), functionally benign (duplicate JS object key → last one wins, same value), and the runtime reader de-dupes — but hyperframes lint does not flag it, so it silently dirties design sources.
Repro
- Composition with a non-grouped element and a keyframed tween containing an intermediate eased keyframe, e.g.:
tl.to("#box", { keyframes: {
"0%": { scale: 1 },
"60%": { scale: 1.18, ease: "power2.out" },
"100%": { scale: 1 }
}, duration: 4 });
- Move the playhead onto the eased
60% keyframe.
- Move or resize
#box on the canvas (this fires an add-keyframe that merges into the existing 60% record).
- Inspect the source.
Expected: "60%": { scale: <new>, x: <new>, y: <new>, ease: "power2.out" } (single ease).
Actual: "60%": { scale: <new>, ease: "power2.out", x: <new>, y: <new>, ease: "power2.out" } (duplicate ease).
Scope (tested matrix)
| Mutation |
Starting keyframe |
Result |
add-keyframe {x,y} |
{scale, ease} (eased) |
DUP {scale, ease, x, y, ease} |
add-keyframe {scale} |
{scale, ease} (eased) |
DUP {scale, ease, ease} |
add-keyframe {x,y} |
{scale} (non-eased) |
clean |
update-keyframe {x,y} |
{scale, ease} (eased) |
clean (upsertProp is dedup-safe) |
So it triggers only on add-keyframe merging into an already-eased keyframe (the canvas move/resize gesture while the playhead sits on an eased keyframe). Inspector field edits (update-keyframe) are unaffected.
Mechanism (cli.js)
addKeyframeToScript2, "existing ObjectExpression" branch:
const merged = { ...existingRecord }; // existingRecord already contains `ease`
for (...properties...) merged[k] = v;
existing.prop.value = buildKeyframeValueNode(
merged, // merged carries the `ease` key
ease ?? (typeof existingRecord.ease === "string" ? existingRecord.ease : void 0) // and `ease` is passed again as arg
);
buildKeyframeValueNode(properties, ease) emits ease twice: once from Object.entries(properties) (the merged.ease) and once from the trailing if (ease) entries.push("ease: …"). → duplicate key.
Suggested fix: in buildKeyframeValueNode, skip the trailing ease push if properties already carries an ease key (or drop ease from merged before passing it). Either makes the two paths mutually exclusive.
Severity: low — bounded to 2, benign at render/runtime. Reported mainly because lint is blind to it, so it accumulates invisibly in design sources. Same serialization family as #1800.
Version: 0.7.21
Summary
When a canvas move/resize lands an
add-keyframemutation on top of an existing keyframe that already has anease, the serializer writes theeasekey twice in the same keyframe object. It's bounded to 2 (never 3+), functionally benign (duplicate JS object key → last one wins, same value), and the runtime reader de-dupes — buthyperframes lintdoes not flag it, so it silently dirties design sources.Repro
60%keyframe.#boxon the canvas (this fires anadd-keyframethat merges into the existing60%record).Expected:
"60%": { scale: <new>, x: <new>, y: <new>, ease: "power2.out" }(singleease).Actual:
"60%": { scale: <new>, ease: "power2.out", x: <new>, y: <new>, ease: "power2.out" }(duplicateease).Scope (tested matrix)
add-keyframe{x,y}{scale, ease}(eased){scale, ease, x, y, ease}add-keyframe{scale}{scale, ease}(eased){scale, ease, ease}add-keyframe{x,y}{scale}(non-eased)update-keyframe{x,y}{scale, ease}(eased)upsertPropis dedup-safe)So it triggers only on
add-keyframemerging into an already-eased keyframe (the canvas move/resize gesture while the playhead sits on an eased keyframe). Inspector field edits (update-keyframe) are unaffected.Mechanism (cli.js)
addKeyframeToScript2, "existing ObjectExpression" branch:buildKeyframeValueNode(properties, ease)emitseasetwice: once fromObject.entries(properties)(themerged.ease) and once from the trailingif (ease) entries.push("ease: …"). → duplicate key.Suggested fix: in
buildKeyframeValueNode, skip the trailingeasepush ifpropertiesalready carries aneasekey (or dropeasefrommergedbefore passing it). Either makes the two paths mutually exclusive.Severity: low — bounded to 2, benign at render/runtime. Reported mainly because
lintis blind to it, so it accumulates invisibly in design sources. Same serialization family as #1800.