@@ -142,6 +142,96 @@ function upsertProp(ms: MagicString, objNode: any, key: string, value: unknown):
142142 }
143143}
144144
145+ /**
146+ * Vars keys that are NOT editable transform/style props: builtins
147+ * (duration/ease/delay), dropped callbacks, and extras (stagger/yoyo/repeat/…).
148+ * The exact union of recast's BUILTIN_VAR_KEYS + DROPPED_VAR_KEYS + EXTRAS_KEYS,
149+ * so both writers classify vars keys identically. (Distinct from the keyframe-
150+ * conversion NON_EDITABLE_VAR_KEYS below, which intentionally omits `ease`
151+ * because that path re-emits ease separately.)
152+ */
153+ const NON_EDITABLE_PROP_KEYS = new Set ( [
154+ "duration" ,
155+ "ease" ,
156+ "delay" ,
157+ "onComplete" ,
158+ "onStart" ,
159+ "onUpdate" ,
160+ "onRepeat" ,
161+ "stagger" ,
162+ "yoyo" ,
163+ "repeat" ,
164+ "repeatDelay" ,
165+ "snap" ,
166+ "overwrite" ,
167+ "immediateRender" ,
168+ ] ) ;
169+
170+ /**
171+ * Editable transform/style key test: anything NOT a builtin, dropped callback, or
172+ * extras key. Mirrors recast's isEditablePropertyKey so both writers classify
173+ * vars keys identically.
174+ */
175+ function isEditableVarKey ( key : string ) : boolean {
176+ return ! NON_EDITABLE_PROP_KEYS . has ( key ) ;
177+ }
178+
179+ /**
180+ * Collect verbatim `key: value` entries to PRESERVE from a vars/keyframe
181+ * ObjectExpression: every property whose key `drop` does not reject, sliced from
182+ * source — except keys present in `overrides`, whose value is replaced. Returns
183+ * the entries plus the set of keys it kept, so callers can append new keys.
184+ */
185+ function preservedEntries (
186+ objNode : any ,
187+ source : string ,
188+ drop : ( key : string ) => boolean ,
189+ overrides : Record < string , unknown > ,
190+ ) : { entries : string [ ] ; keys : Set < string > } {
191+ const entries : string [ ] = [ ] ;
192+ const keys = new Set < string > ( ) ;
193+ for ( const prop of objNode . properties ?? [ ] ) {
194+ if ( ! isObjectProperty ( prop ) ) continue ;
195+ const key = propKeyName ( prop ) ;
196+ if ( typeof key !== "string" || drop ( key ) ) continue ;
197+ keys . add ( key ) ;
198+ const code =
199+ key in overrides
200+ ? valueToCode ( overrides [ key ] )
201+ : source . slice ( prop . value . start , prop . value . end ) ;
202+ entries . push ( `${ safeKey ( key ) } : ${ code } ` ) ;
203+ }
204+ return { entries, keys } ;
205+ }
206+
207+ /**
208+ * Replace the editable-property keys on a vars ObjectExpression with exactly
209+ * `newProps`, leaving non-editable keys (duration/ease/stagger/callbacks/…)
210+ * untouched unless overridden in `nonEditableOverrides`. Mirrors recast's
211+ * reconcileEditableProperties: editable keys absent from `newProps` are DROPPED,
212+ * not merged. Rebuilt in a single ms.overwrite so the splice can never overlap a
213+ * sibling edit — non-editable updates that also target this node (duration/ease/
214+ * extras) are folded into the same rebuild rather than spliced separately.
215+ */
216+ function reconcileEditableProps (
217+ ms : MagicString ,
218+ objNode : any ,
219+ source : string ,
220+ newProps : Record < string , number | string > ,
221+ nonEditableOverrides ?: Record < string , unknown > ,
222+ ) : void {
223+ if ( objNode ?. type !== "ObjectExpression" ) return ;
224+ const overrides = nonEditableOverrides ?? { } ;
225+ const { entries, keys } = preservedEntries ( objNode , source , isEditableVarKey , overrides ) ;
226+ for ( const [ key , value ] of Object . entries ( overrides ) ) {
227+ if ( ! keys . has ( key ) ) entries . push ( `${ safeKey ( key ) } : ${ valueToCode ( value ) } ` ) ;
228+ }
229+ for ( const [ key , value ] of Object . entries ( newProps ) ) {
230+ entries . push ( `${ safeKey ( key ) } : ${ valueToCode ( value ) } ` ) ;
231+ }
232+ ms . overwrite ( objNode . start , objNode . end , `{ ${ entries . join ( ", " ) } }` ) ;
233+ }
234+
145235// ── Insertion helpers ─────────────────────────────────────────────────────────
146236
147237/** Traverse callee.object chain to check if a call ultimately roots at timelineVar. */
@@ -184,24 +274,35 @@ export function updateAnimationInScript(
184274 const ms = new MagicString ( script ) ;
185275 const { call } : { call : TweenCallInfo } = target ;
186276
187- if ( updates . duration !== undefined ) {
188- upsertProp ( ms , call . varsArg , "duration" , updates . duration ) ;
189- }
190-
191- if ( updates . ease !== undefined ) {
192- upsertProp ( ms , call . varsArg , "ease" , updates . ease ) ;
193- }
194-
277+ // When `properties` is present we REPLACE the editable set (recast parity:
278+ // editable keys absent from the update are dropped). Fold any concurrent
279+ // non-editable updates (duration/ease/extras) into the single varsArg rebuild
280+ // so their splices can't overlap the rebuild's overwrite of the whole node.
195281 if ( updates . properties ) {
196- for ( const [ key , value ] of Object . entries ( updates . properties ) ) {
197- upsertProp ( ms , call . varsArg , key , value ) ;
282+ const overrides : Record < string , unknown > = { } ;
283+ if ( updates . duration !== undefined ) overrides . duration = updates . duration ;
284+ if ( updates . ease !== undefined ) overrides . ease = updates . ease ;
285+ if ( updates . extras ) Object . assign ( overrides , updates . extras ) ;
286+ reconcileEditableProps ( ms , call . varsArg , script , updates . properties , overrides ) ;
287+ } else {
288+ if ( updates . duration !== undefined ) {
289+ upsertProp ( ms , call . varsArg , "duration" , updates . duration ) ;
290+ }
291+ if ( updates . ease !== undefined ) {
292+ upsertProp ( ms , call . varsArg , "ease" , updates . ease ) ;
293+ }
294+ if ( updates . extras ) {
295+ for ( const [ key , value ] of Object . entries ( updates . extras ) ) {
296+ upsertProp ( ms , call . varsArg , key , value ) ;
297+ }
198298 }
199299 }
200300
201301 if ( updates . fromProperties && call . method === "fromTo" && call . fromArg ) {
202- for ( const [ key , value ] of Object . entries ( updates . fromProperties ) ) {
203- upsertProp ( ms , call . fromArg , key , value ) ;
204- }
302+ // fromTo's from-vars carry only editable props — REPLACE them too (recast
303+ // parity). fromArg is a distinct node from varsArg, so this rebuild never
304+ // overlaps the varsArg edits above.
305+ reconcileEditableProps ( ms , call . fromArg , script , updates . fromProperties ) ;
205306 }
206307
207308 if ( updates . position !== undefined ) {
@@ -214,12 +315,6 @@ export function updateAnimationInScript(
214315 }
215316 }
216317
217- if ( updates . extras ) {
218- for ( const [ key , value ] of Object . entries ( updates . extras ) ) {
219- upsertProp ( ms , call . varsArg , key , value ) ;
220- }
221- }
222-
223318 return ms . toString ( ) ;
224319}
225320
@@ -715,6 +810,29 @@ function insertNewKeyframe(
715810 }
716811}
717812
813+ /**
814+ * Rebuild a vars ObjectExpression that has just dropped below two keyframes,
815+ * collapsing `keyframes: {…}` back to a flat tween. Mirrors recast's
816+ * collapseKeyframesToFlat: drop the `keyframes` + `easeEach` keys, preserve every
817+ * other vars key verbatim, and splice the remaining keyframe's properties (minus
818+ * its per-keyframe `ease`) in as flat vars keys. Single ms.overwrite of the whole
819+ * vars node so the splice can't overlap the keyframe removal.
820+ */
821+ function collapseKeyframesToFlat (
822+ ms : MagicString ,
823+ varsNode : any ,
824+ source : string ,
825+ remainingRecord : Record < string , number | string > ,
826+ ) : void {
827+ if ( varsNode ?. type !== "ObjectExpression" ) return ;
828+ const dropKeyframeKeys = ( key : string ) => key === "keyframes" || key === "easeEach" ;
829+ const { entries } = preservedEntries ( varsNode , source , dropKeyframeKeys , { } ) ;
830+ for ( const [ k , v ] of Object . entries ( remainingRecord ) ) {
831+ if ( k !== "ease" ) entries . push ( `${ safeKey ( k ) } : ${ valueToCode ( v ) } ` ) ;
832+ }
833+ ms . overwrite ( varsNode . start , varsNode . end , `{ ${ entries . join ( ", " ) } }` ) ;
834+ }
835+
718836export function removeKeyframeFromScript (
719837 script : string ,
720838 animationId : string ,
@@ -732,8 +850,20 @@ export function removeKeyframeFromScript(
732850 const match = findKfPropByPct ( kfNode , percentage ) ;
733851 if ( ! match ) return script ;
734852
735- const allProps = ( kfNode . properties ?? [ ] ) . filter ( ( p : any ) => isObjectProperty ( p ) ) ;
736853 const ms = new MagicString ( script ) ;
854+
855+ // If removing this keyframe leaves fewer than two, collapse the keyframes
856+ // object back to a flat tween (recast parity) instead of leaving a lone
857+ // keyframe. We rebuild the whole vars node, so we never also splice the kf
858+ // node — the two edits would overlap.
859+ const remaining = percentagePropsOf ( kfNode ) . filter ( ( p ) => p !== match . prop ) ;
860+ if ( remaining . length < 2 ) {
861+ const record = remaining . length === 1 ? valueNodeToRecord ( remaining [ 0 ] ! . value , script ) : { } ;
862+ collapseKeyframesToFlat ( ms , target . call . varsArg , script , record ) ;
863+ return ms . toString ( ) ;
864+ }
865+
866+ const allProps = ( kfNode . properties ?? [ ] ) . filter ( ( p : any ) => isObjectProperty ( p ) ) ;
737867 removeProp ( ms , match . prop , allProps ) ;
738868 return ms . toString ( ) ;
739869}
0 commit comments