diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 671140639c4..2505d1b25bb 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -22,6 +22,7 @@ import { safeJsKey as safeKey, resolveConversionProps, mergePercentageKeyframes, + authorsKeyframes, } from "./gsapSerialize"; export type { @@ -1548,7 +1549,7 @@ export function updateAnimationInScript( return script; } const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; + if (!target || (updates.properties && target.animation.hasUnresolvedKeyframes)) return script; applyUpdatesToCall(target.call, updates); return recast.print(parsed.ast).code; } @@ -1940,14 +1941,14 @@ export function splitAnimationsInScript( const dur = anim.duration ?? 0; const animEnd = pos + dur; - if (anim.keyframes) { + if (authorsKeyframes(anim)) { if (pos >= opts.splitTime) { result = updateAnimationSelector(result, anim.id, newSelector); } else if (animEnd > opts.splitTime) { // Spanning keyframes can't be correctly split without renormalizing // percentages and durations — leave on original, warn the caller. skippedSelectors.push(`${originalSelector} (keyframes spanning split)`); - const kfs = anim.keyframes.keyframes; + const kfs = anim.keyframes?.keyframes ?? []; for (const kf of kfs) { const kfTime = pos + (kf.percentage / 100) * dur; if (kfTime <= opts.splitTime) { @@ -1958,7 +1959,10 @@ export function splitAnimationsInScript( } } else { // Entirely before split — extract final keyframe properties - const kfs = anim.keyframes.keyframes; + if (anim.hasUnresolvedKeyframes) { + skippedSelectors.push(`${originalSelector} (unreadable keyframes before split)`); + } + const kfs = anim.keyframes?.keyframes ?? []; if (kfs.length > 0) { for (const [k, v] of Object.entries(kfs[kfs.length - 1]!.properties)) { inheritedProps[k] = v; @@ -2714,7 +2718,7 @@ export function convertToKeyframesInScript( if (!loc) return script; const anim = loc.target.animation; - if (anim.keyframes) return script; + if (authorsKeyframes(anim)) return script; const { fromProps, toProps } = resolveConversionProps(anim, resolvedFromValues); const varsArg = loc.target.call.varsArg; @@ -3263,6 +3267,7 @@ export function splitIntoPropertyGroups( if (!loc) return { script, ids: [animationId] }; const anim = loc.target.animation; + if (anim.hasUnresolvedKeyframes) return { script, ids: [animationId] }; // Collect the properties to partition. For keyframed tweens, gather the // union of all properties across all keyframes. For flat tweens, use the diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts index bcdd9315461..99b7dd16217 100644 --- a/packages/parsers/src/gsapParserAcorn.ts +++ b/packages/parsers/src/gsapParserAcorn.ts @@ -1004,7 +1004,7 @@ function parsePercentageKeyframes( node: any, scope: ScopeBindings, source: string, -): GsapKeyframesData { +): GsapKeyframesData | undefined { const keyframes: GsapPercentageKeyframe[] = []; let ease: string | undefined; let easeEach: string | undefined; @@ -1016,6 +1016,7 @@ function parsePercentageKeyframes( const pctMatch = PERCENTAGE_KEY_RE.exec(key); if (pctMatch) { + if (!isPlainStep(prop.value)) return undefined; const percentage = Number.parseFloat(pctMatch[1] ?? "0"); const record = objectExpressionToRecord(prop.value, scope, source); const properties: Record = {}; @@ -1040,6 +1041,8 @@ function parsePercentageKeyframes( ease = tryResolveStringProp(prop.value, scope, source) ?? ease; } else if (key === "easeEach") { easeEach = tryResolveStringProp(prop.value, scope, source) ?? easeEach; + } else { + return undefined; } } @@ -1072,8 +1075,20 @@ function computeKeyframesTotalDuration( return getObjectArrayKeyframeTiming(durations)?.totalDuration; } -const isFullyReadableStep = (el: any) => - el?.type === "ObjectExpression" && !el.properties.some((p: any) => p.type === "SpreadElement"); +function staticKeyName(prop: any): string | undefined { + if (prop.type !== "ObjectProperty" && prop.type !== "Property") return undefined; + if (prop.method || (prop.kind && prop.kind !== "init")) return undefined; + const key = prop.computed + ? prop.key?.type === "Literal" && prop.key.value + : (prop.key?.name ?? prop.key?.value); + return typeof key === "string" ? key : undefined; +} + +const isPlainObject = (node: any) => + node?.type === "ObjectExpression" && + (node.properties ?? []).every((p: any) => staticKeyName(p) !== undefined); + +const isPlainStep = (node: any) => node?.type === "Literal" || isPlainObject(node); // fallow-ignore-next-line complexity function parseObjectArrayKeyframes( @@ -1089,7 +1104,7 @@ function parseObjectArrayKeyframes( }> = []; for (const el of elements) { - if (!isFullyReadableStep(el)) return undefined; + if (!isPlainObject(el)) return undefined; const record = objectExpressionToRecord(el, scope, source); const properties: Record = {}; let duration: unknown; @@ -1121,7 +1136,7 @@ function parseObjectArrayKeyframes( } // fallow-ignore-next-line complexity -function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData { +function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyframesData | undefined { const arrayProps: Map = new Map(); let ease: string | undefined; let easeEach: string | undefined; @@ -1135,9 +1150,8 @@ function parseSimpleArrayKeyframes(node: any, scope: ScopeBindings): GsapKeyfram const values: (number | string)[] = []; for (const el of prop.value.elements ?? []) { const val = resolveNode(el, scope); - if (typeof val === "number" || typeof val === "string") { - values.push(val); - } + if (typeof val !== "number" && typeof val !== "string") return undefined; + values.push(val); } if (values.length > 0) arrayProps.set(key, values); } else if (key === "ease") { @@ -1182,6 +1196,7 @@ function parseKeyframesNode( if (node.type !== "ObjectExpression") return undefined; const props = node.properties ?? []; + if (!props.every((p: any) => staticKeyName(p) !== undefined)) return undefined; let hasPercentageKey = false; let hasArrayValue = false; diff --git a/packages/parsers/src/gsapSerialize.ts b/packages/parsers/src/gsapSerialize.ts index d2651f8d6dc..33c898317ae 100644 --- a/packages/parsers/src/gsapSerialize.ts +++ b/packages/parsers/src/gsapSerialize.ts @@ -47,6 +47,10 @@ export function editabilityForProvenance(provenance?: GsapProvenance): KeyframeE return "unroll"; } +export function authorsKeyframes(anim: GsapAnimation): boolean { + return anim.keyframes !== undefined || anim.hasUnresolvedKeyframes === true; +} + export interface GsapAnimation { id: string; targetSelector: string; diff --git a/packages/parsers/src/gsapUnroll.test.ts b/packages/parsers/src/gsapUnroll.test.ts index 74703233058..f377f9d876e 100644 --- a/packages/parsers/src/gsapUnroll.test.ts +++ b/packages/parsers/src/gsapUnroll.test.ts @@ -228,6 +228,13 @@ fade("#a", 1);`; expect(unrollComputedTimeline(script)).toBe(script); }); + it("leaves a loop as authored when a tween's keyframes cannot be read", () => { + const script = `const tl = gsap.timeline(); +for (let i = 0; i < 2; i++) { tl.to("#x", { duration: 1, keyframes: [{ x: 0 }, { x: 100, runBackwards: true }] }, i); }`; + expect(parseGsapScriptAcorn(script).animations[0]!.hasUnresolvedKeyframes).toBe(true); + expect(unrollComputedTimeline(script)).toBe(script); + }); + it("leaves a loop as authored when its body sets state outside the timeline", () => { const script = `const tl = gsap.timeline(); for (let i = 0; i < 2; i++) { gsap.set("#x", { opacity: 0 }); tl.to("#x", { opacity: 1, duration: 1 }, i); }`; diff --git a/packages/parsers/src/gsapUnroll.ts b/packages/parsers/src/gsapUnroll.ts index 35cecbb53bc..8946e01eeb8 100644 --- a/packages/parsers/src/gsapUnroll.ts +++ b/packages/parsers/src/gsapUnroll.ts @@ -227,14 +227,18 @@ function declaredFunctions(stmt: Node): Array<[string, Node[]]> { const collectHelperBodies = (statements: Node[]): Map => new Map(statements.flatMap(declaredFunctions)); -/** Statements stay as authored when literal tweens cannot encode their timing, or they do more than add tweens. */ +/** Left as authored when literal tweens can't encode the timing or keyframes, or it does more than add tweens. */ function dropStatementsUnsafeToUnroll( byStatement: Map, ctx: UnrollScope, ): void { for (const [stmt, anims] of byStatement) { const unknownTiming = anims.some( - (a) => a.durationUnresolved || a.resolvedStart === undefined || a.hasUnresolvedSelector, + (a) => + a.durationUnresolved || + a.resolvedStart === undefined || + a.hasUnresolvedSelector || + a.hasUnresolvedKeyframes, ); const bodyStmts = unknownTiming ? null : bodyOf(stmt, ctx.helpers); if (bodyStmts === null || !onlyAddsTweens(bodyStmts, ctx)) byStatement.delete(stmt); diff --git a/packages/parsers/src/gsapWriter.parity.test.ts b/packages/parsers/src/gsapWriter.parity.test.ts index 84401d55be3..120a342a2eb 100644 --- a/packages/parsers/src/gsapWriter.parity.test.ts +++ b/packages/parsers/src/gsapWriter.parity.test.ts @@ -18,6 +18,7 @@ import { materializeKeyframesInScript as materializeRecast, splitIntoPropertyGroups as splitGroupsRecast, splitAnimationsInScript as splitAnimsRecast, + updateAnimationInScript as updateAnimRecast, setArcPathInScript as setArcRecast, updateArcSegmentInScript as updateArcSegmentRecast, removeArcPathFromScript as removeArcRecast, @@ -55,6 +56,7 @@ import { resizeKeyframedTweenInScript as resizeKeyframedTweenAcorn, addAnimationWithKeyframesToScript as addWithKfAcorn, removeAnimationFromScript as removeAnimAcorn, + updateAnimationInScript as updateAnimAcorn, shiftPositionsInScript as shiftAcorn, scalePositionsInScript as scaleAcorn, retimeClipTweensInScript, @@ -570,6 +572,150 @@ describe("parity: splitAnimationsInScript (recast vs acorn)", () => { }); }); +describe("splitAnimationsInScript keeps keyframes the parser cannot read", () => { + const STEP_SHAPES = [ + ["a step with a flag", "", "[{ x: 0 }, { x: 100, runBackwards: true }]"], + ["a spread step", "const base = { x: 0 };", "[{ ...base }, { x: 100 }]"], + ["a named step", "const last = { x: 100 };", "[{ x: 0 }, last]"], + ["keyframes from a call", "", "steps()"], + ] as const; + const opts = { originalId: "a", newId: "a-2", elementStart: 0, elementDuration: 4 }; + const writers = [ + ["recast", splitAnimsRecast], + ["acorn", splitAnimsAcorn], + ] as const; + + for (const [writer, split] of writers) { + for (const [shape, decl, keyframes] of STEP_SHAPES) { + const script = `const tl = gsap.timeline({ paused: true }); +${decl} +tl.to("#a", { duration: 2, keyframes: ${keyframes} }, 0);`; + + it(`${writer}: leaves ${shape} spanning the split as authored`, () => { + expect(parseGsapScriptAcorn(script).animations[0]!.hasUnresolvedKeyframes).toBe(true); + const result = split(script, { ...opts, splitTime: 1 }); + expect(result.script).toContain(`tl.to("#a", { duration: 2, keyframes: ${keyframes} }, 0)`); + expect(result.skippedSelectors).toContain("#a (keyframes spanning split)"); + }); + + it(`${writer}: moves ${shape} after the split whole`, () => { + const result = split(script, { ...opts, splitTime: 0 }); + expect(result.script).toContain( + `tl.to("#a-2", { duration: 2, keyframes: ${keyframes} }, 0)`, + ); + }); + } + } +}); + +describe("writers leave keyframes they cannot read as authored", () => { + const UNREADABLE = [ + ["a step with a flag", "[{ x: 0 }, { x: 100, runBackwards: true }]"], + ["keyframes from a call", "steps()"], + ] as const; + const opts = { originalId: "a", newId: "a-2", elementStart: 0, elementDuration: 4 }; + + for (const [shape, keyframes] of UNREADABLE) { + const script = `const tl = gsap.timeline({ paused: true }); +tl.to("#a", { opacity: 1, x: 5, duration: 1, keyframes: ${keyframes} }, 0);`; + const id = () => acornId(script); + + for (const [writer, split] of [ + ["recast", splitAnimsRecast], + ["acorn", splitAnimsAcorn], + ] as const) { + if (writer === "recast" && keyframes !== "steps()") continue; + it(`${writer}: a split after ${shape} reports that the new clip's start is unknown`, () => { + const result = split(`${script}\ntl.to("#a", { duration: 2, x: 200 }, 1);`, { + ...opts, + splitTime: 2, + }); + expect(result.skippedSelectors).toContain("#a (unreadable keyframes before split)"); + }); + } + + it.each([ + ["recast convert to keyframes", () => convertRecast(script, id())], + ["acorn convert to keyframes", () => convertAcorn(script, id())], + ["recast split into property groups", () => splitGroupsRecast(script, id()).script], + ["acorn split into property groups", () => splitGroupsAcorn(script, id()).script], + ["acorn add keyframe", () => addKeyframeAcorn(script, id(), 50, { x: 10 })], + ["acorn update keyframe", () => updateKeyframeAcorn(script, id(), 100, { x: 10 })], + ["acorn remove keyframe", () => removeKeyframeAcorn(script, id(), 100)], + ["acorn move keyframe", () => moveKeyframeAcorn(script, id(), 100, 80)], + ["acorn resize keyframed tween", () => resizeKeyframedTweenAcorn(script, id(), 0, 2, [])], + [ + "acorn property edit", + () => updateAnimAcorn(script, id(), { properties: { opacity: 0.5 } }), + ], + ])(`%s leaves ${shape} unchanged`, (_name, write) => { + expect(parseGsapScriptAcorn(script).animations[0]!.hasUnresolvedKeyframes).toBe(true); + expect(write()).toBe(script); + }); + } + + it.each(["{ x: v }", "{ [v]: { x: 1 } }", "{ ...o }"])( + "acorn add and resize leave object keyframes %s unchanged", + (keyframes) => { + const script = `const tl = gsap.timeline({ paused: true }); +tl.to("#a", { duration: 1, keyframes: ${keyframes} }, 0);`; + expect(parseGsapScriptAcorn(script).animations[0]!.hasUnresolvedKeyframes).toBe(true); + expect(addKeyframeAcorn(script, acornId(script), 50, { x: 5 })).toBe(script); + expect(resizeKeyframedTweenAcorn(script, acornId(script), 0, 2, [])).toBe(script); + }, + ); + + it.each([ + ["recast", splitAnimsRecast], + ["acorn", splitAnimsAcorn], + ] as const)("%s: a split across unreadable keyframes writes no start value", (_writer, split) => { + const script = `const tl = gsap.timeline({ paused: true }); +tl.to("#a", { opacity: 0.5, duration: 2, keyframes: steps() }, 0);`; + const result = split(script, { ...opts, splitTime: 1 }); + expect(result.script).not.toContain('"#a-2"'); + expect(result.skippedSelectors).toContain("#a (keyframes spanning split)"); + }); + + it("treats keyframes with a computed percentage key as unreadable", () => { + const script = `const tl = gsap.timeline({ paused: true }); +tl.to("#a", { duration: 1, keyframes: { [k]: { x: 10 }, "100%": { x: 50 } } }, 0);`; + const id = acornId(script); + expect(parseGsapScriptAcorn(script).animations[0]!.hasUnresolvedKeyframes).toBe(true); + expect(updateAnimAcorn(script, id, { properties: { opacity: 0.5 } })).toBe(script); + expect(removeKeyframeAcorn(script, id, 100)).toBe(script); + }); + + it.each([ + ["a named step", '{ "0%": { x: 0 }, "100%": last }'], + ["a spread inside a step", '{ "0%": { x: 0 }, "100%": { ...base, x: 10 } }'], + ["a numeric key", '{ "0%": { x: 0 }, [50]: { x: 5 }, "100%": { x: 10 } }'], + ["a template key in an array step", "[{ x: 0 }, { x: 10, [`y`]: 5 }]"], + ["a variable key in an array step", "[{ x: 0 }, { x: 10, [k]: 5 }]"], + ["a getter in a step", '{ "0%": { x: 0 }, "100%": { get x() { return 10; } } }'], + ["a method in an array step", "[{ x: 0 }, { x: 10, y() {} }]"], + ["an array channel beside % steps", '{ "0%": { x: 0 }, "100%": { x: 10 }, y: [0, 5] }'], + ["an unresolved array value", "{ x: [0, 10, v] }"], + ["a spread in an array channel", "{ x: [0, ...rest] }"], + ["a hole in an array channel", "{ x: [0, , 10] }"], + ])( + "treats keyframes with %s as unreadable, so drags and deletes keep them", + (_shape, keyframes) => { + const script = `const tl = gsap.timeline({ paused: true }); +tl.to("#a", { duration: 2, keyframes: ${keyframes} }, 0);`; + const id = acornId(script); + expect(parseGsapScriptAcorn(script).animations[0]!.hasUnresolvedKeyframes).toBe(true); + expect(moveKeyframeAcorn(script, id, 100, 80)).toBe(script); + expect(removeKeyframeAcorn(script, id, 0)).toBe(script); + }, + ); + + it("recast property edit leaves keyframes from a call unchanged", () => { + const script = `const tl = gsap.timeline({ paused: true }); +tl.to("#a", { x: 5, duration: 1, keyframes: steps() }, 0);`; + expect(updateAnimRecast(script, acornId(script), { properties: { x: 9 } })).toBe(script); + }); +}); + // ─── arc path parity ────────────────────────────────────────────────────────── const ARC_FLAT_SCRIPT = ` diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 19c30190423..813f39db41f 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -14,6 +14,7 @@ import type { ArcPathSegment, } from "./gsapSerialize.js"; import { + authorsKeyframes, resolveConversionProps, extractArcWaypoints, buildMotionPathObjectCode, @@ -329,7 +330,7 @@ export function updateAnimationInScript( const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; + if (!target || (updates.properties && target.animation.hasUnresolvedKeyframes)) return script; const ms = new MagicString(script); const { call }: { call: TweenCallInfo } = target; @@ -1078,7 +1079,7 @@ export function updateKeyframeInScript( const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; + if (!target || target.animation.hasUnresolvedKeyframes) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); if (!kfPropNode) { @@ -1198,7 +1199,7 @@ function locateWithKeyframes( const target = parsed.located.find((l) => l.id === animationId) ?? parsed.located.find((l) => l.id === convertedId); - if (!target) return null; + if (!target || target.animation.hasUnresolvedKeyframes) return null; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); if (!kfPropNode || kfPropNode.value?.type !== "ObjectExpression") return null; return { script, parsed, target, kfNode: kfPropNode.value }; @@ -1245,7 +1246,7 @@ function ensureKeyframesNode( const parsed = parseGsapScriptAcornForWrite(script); const target = parsed?.located.find((l) => l.id === animationId); - if (!target) return null; + if (!target || target.animation.hasUnresolvedKeyframes) return null; // Array-form keyframes → normalize to object form, then re-locate. const kfProp = findPropertyNode(target.call.varsArg, "keyframes"); @@ -1421,7 +1422,7 @@ export function removeKeyframeFromScript( const parsed = parseGsapScriptAcornForWrite(script); if (!parsed) return script; const target = parsed.located.find((l) => l.id === animationId); - if (!target) return script; + if (!target || target.animation.hasUnresolvedKeyframes) return script; const kfPropNode = findPropertyNode(target.call.varsArg, "keyframes"); if (!kfPropNode) return script; @@ -1690,7 +1691,7 @@ export function convertToKeyframesFromScript( const target = parsed.located.find((l) => l.id === animationId); if (!target) return script; const { animation, call } = target; - if (animation.keyframes) return script; + if (authorsKeyframes(animation)) return script; const isSet = call.method === "set"; const { fromProps, toProps } = resolveConversionProps(animation, resolvedFromValues); @@ -2027,6 +2028,7 @@ export function splitIntoPropertyGroupsFromScript( const target = parsed.located.find((l) => l.id === animationId); if (!target) return { script, ids: [animationId] }; const { animation } = target; + if (animation.hasUnresolvedKeyframes) return { script, ids: [animationId] }; const allPropKeys = collectPropertyKeys(animation); const groupProps = partitionPropertyGroups(allPropKeys); @@ -2526,8 +2528,8 @@ function computeForwardBaselines( const dur = anim.duration ?? 0; const animEnd = pos + dur; - if (anim.keyframes) { - const kfs = anim.keyframes.keyframes; + if (authorsKeyframes(anim)) { + const kfs = anim.keyframes?.keyframes ?? []; if (pos >= splitTime) { // Moves wholly to the new element — contributes nothing to the baseline. } else if (animEnd > splitTime) { @@ -2612,6 +2614,24 @@ type SplitCtx = { newElementStart: number; }; +function applyKeyframedTweenSplit( + result: string, + anim: GsapAnimation, + pos: number, + animEnd: number, + ctx: SplitCtx, + skippedSelectors: string[], +): string { + if (pos >= ctx.splitTime) + return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); + if (animEnd > ctx.splitTime) { + skippedSelectors.push(`${ctx.originalSelector} (keyframes spanning split)`); + } else if (anim.hasUnresolvedKeyframes) { + skippedSelectors.push(`${ctx.originalSelector} (unreadable keyframes before split)`); + } + return result; +} + // Decide what one matching tween does at the split point: move to the new // element (wholly after), stay (wholly before / keyframes before), get skipped // (keyframes spanning), or get interpolated in half (spanning). Returns the @@ -2627,14 +2647,8 @@ function applyTweenSplit( const dur = anim.duration ?? 0; const animEnd = pos + dur; - if (anim.keyframes) { - if (pos >= ctx.splitTime) - return updateAnimationSelectorInScript(result, anim.id, ctx.newSelector); - if (animEnd > ctx.splitTime) { - skippedSelectors.push(`${ctx.originalSelector} (keyframes spanning split)`); - } - // Inherited-state for kf tweens is handled by computeForwardBaselines. - return result; + if (authorsKeyframes(anim)) { + return applyKeyframedTweenSplit(result, anim, pos, animEnd, ctx, skippedSelectors); } // Wholly before the split — kept on the original element. if (animEnd <= ctx.splitTime) return result;