fix(parsers): exclude per-keyframe duration from object-keyframe properties#2452
fix(parsers): exclude per-keyframe duration from object-keyframe properties#2452miguel-heygen wants to merge 1 commit into
Conversation
…erties A %-keyed object keyframe step's duration (GSAP array-keyframe segment timing) was collected as an animatable property, so it surfaced as a bogus duration keyframe lane and got round-tripped as a property — corrupting the tween on the next manual edit in Studio. Both the live acorn read parser and the retired recast parser had the leak; the array-form parser already excludes it. Skip duration in the %-keyed extraction to match.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 492b49deba816a4c2f7771d9a2c952d43bcc927d.
What this does
Both %-keyed keyframe extractors in the parser layer — parsePercentageKeyframes in gsapParserAcorn.ts:854 (live) and its retired sibling in gsapParser.ts:686 — now skip a per-step duration when walking the property record. In GSAP, duration is array-keyframe segment timing (between-keyframe pacing), not an animatable value; a %-keyed step's timing is owned by the % key itself, so a stray duration here is neither timing nor a property. Without this guard, Studio surfaced it as a bogus lane and round-tripped it back as an animatable value, corrupting the tween on next edit.
Verification
- Parity with array-form parser —
parseObjectArrayKeyframesalready excludesdurationin the array-form (per PR body). This closes the divergence between array and%-keyed extractors so a shape-converted keyframe (array → object) doesn't silently gain a phantom lane. - Per-keyframe
easepreserved — the guard is placed inside theelse ifchain AFTER theeasebranch, so{x: 100, duration: 6, ease: "power2.out"}still emitskfEase = "power2.out"and props{x: 100}. Both acorn and retired test cases assert this on the middle step (kfs[1]?.ease === "power2.out"). Load-bearing. - Real animatable prop preserved — both tests assert
kf.propertiescontainsxwhile lackingdurationfor all three keyframes. Would fail cleanly if the guard were placed before theeasebranch or if the extractor short-circuited the whole iteration. - Both parser suites covered — new tests at
gsapParser.test.ts:659-676andgsapParserAcorn.full.test.ts:87-102use identical scripts, keeping the recast/acorn parity explicit. - Comment quality — the multi-line comment names WHY duration in a
%-keyframe is meaningless AND names the failure mode ("bogus lane", "corrupts on next edit"). Future readers get context without git-blame.
Adversarial pass
- What if a GSAP plugin legitimately uses
durationas an animatable value? — GSAP core reservesdurationuniversally; any plugin author using it as a property would collide with tween-level duration already. Not a realistic case. - Top-level
tl.to(target, {duration: 1, keyframes: {...}})— thatdurationis the tween's own timing, parsed separately by the enclosing tween-parameter reader. This fix scopes only to the values INSIDE each%key. No collision. - Round-trip destructiveness — if any on-disk composition ships with a
%-keyframe containingduration, the parser silently drops it on load. Per the PR framing, that value was meaningless anyway; dropping it is the intended cleanup path. Not a data-loss concern for legitimate content. - Non-
%-keyed object keyframes — theparsePercentageKeyframescode path only fires when keys are%-suffixed literals (validated upstream). Other object-keyframe shapes flow through different extractors. Scope is correctly narrow. - Merge order noted by Magi — this touches
gsapParserAcorn.tsalongside #2413's larger identity-consolidation. Since both add non-overlapping edits (thedurationguard here vs.expandedScopeNodecapture in #2413), a rebase should be trivial mechanical merge. Behaviorally independent as claimed.
Verdict framing
Small, focused hardening with load-bearing parity tests on both parsers. Nothing to hold up. LGTM from my side.
vanceingalls
left a comment
There was a problem hiding this comment.
Runtime-interop lens (paired with Rames on test/observability, whose review I read in full). Additive — Rames covered the mechanics, ease-branch ordering, comment quality, plugin/top-level/round-trip adversarials, and general merge-order framing. Two verifications to add.
Additive verifications
- #2413 overlap check, hard-grepped.
grep -c parsePercentageKeyframes /tmp/pr2413.diff= 0. #2413's hunks ongsapParserAcorn.tsland at lines 39 / 51 / 240 / 264 / 430 / 442 / 1222 / 1324 / 1363 / 1380 / 1818 / 1871 — all incollectScopeBindings/tweenCallToAnimation/ top-level exports.parsePercentageKeyframes(this PR's target, line 854) is untouched. Mechanical merge, not just "should be trivial." - Retired-parser patch is load-bearing, not cosmetic.
gsapParser.test.ts:2-3importsparseGsapScriptdirectly from./gsapParser— the retired Recast module itself, NOT via the exports redirect atgsapParserExports.ts:47that reroutes the publicparseGsapScriptto Acorn. So Recast's own regression + golden + stress + inline test suites (gsapParser.golden.test.ts,gsapParser.stress.test.ts,gsapParser.inline.test.ts) all still exercise this code path. Patching the retired sibling in lockstep keeps that internal parity real, and preventsgsapWriterParity.corpus.test.tsfrom drifting when the writer round-trips a corpus that carries the fixed shape.
Nit — follow-up, not gating
- Consider a mirror test that walks parse → mutate → serialize → re-parse to pin that a stray
durationdoesn't sneak back in via the writer. Current tests assert on parser output only; a writer round-trip closes the "Studio surfaced a bogus lane" loop end-to-end. (gsapWriterAcorn.tsis the target; would live alongside the existinggsapWriter.reviewFixes.test.ts.)
Verdict: APPROVE
Reasoning: Small, symmetric parity fix. Both parsers get identical guards plus affirmative regression tests. Adversarial pass cleared (both Rames's + mine, non-overlapping). Retired Recast patch is load-bearing for internal parity, not dead code. No merge collision with #2413.
— Via
What
Object-form GSAP keyframes (
{ "0%": {...}, "50%": {...} }) that carry a per-stepdurationno longer exposedurationas an animatable keyframe property. Both the live acorn read parser (parsePercentageKeyframes) and the retired recast parser now skipdurationin the%-keyed extraction — matching the array-form parser (parseObjectArrayKeyframes), which already excludes it.Why
durationis GSAP array-keyframe segment timing, not an animatable property. When it leaked into a keyframe'sproperties, the Studio surfaced a bogus "duration" keyframe lane and treated it as editable, corrupting the keyframe round-trip on edit. This is a real, self-contained parser bug and a small hardening.How
A one-line guard in each
%-keyed extraction loop:else if (k === "duration") continue;. No other behavior changes.Test plan
durationand assert:durationis NOT in any keyframe'sproperties; real props (x) and per-keyframeeaseARE preserved.vitest runboth parser suites — 270 passed / 4 skipped.Scope note
This is a parser hardening only. It is not the fix for the "Script error." on manual keyframe edits — that turned out to be a separate custom-ease resolution bug in the Studio runtime (custom eases weren't registered in GSAP's internal ease map, so keyframe segments resolved to a non-function ease and threw on render). That fix lives with the keyframe-timeline feature, not on
main. This PR stands on its own regardless.