Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions packages/parsers/src/gsapParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
safeJsKey as safeKey,
resolveConversionProps,
mergePercentageKeyframes,
authorsKeyframes,
} from "./gsapSerialize";

export type {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
31 changes: 23 additions & 8 deletions packages/parsers/src/gsapParserAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, number | string> = {};
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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(
Expand All @@ -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<string, number | string> = {};
let duration: unknown;
Expand Down Expand Up @@ -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<string, (number | string)[]> = new Map();
let ease: string | undefined;
let easeEach: string | undefined;
Expand All @@ -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") {
Expand Down Expand Up @@ -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;

Expand Down
4 changes: 4 additions & 0 deletions packages/parsers/src/gsapSerialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions packages/parsers/src/gsapUnroll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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); }`;
Expand Down
8 changes: 6 additions & 2 deletions packages/parsers/src/gsapUnroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,14 +227,18 @@ function declaredFunctions(stmt: Node): Array<[string, Node[]]> {
const collectHelperBodies = (statements: Node[]): Map<string, Node[]> =>
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<Node, GsapAnimation[]>,
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);
Expand Down
146 changes: 146 additions & 0 deletions packages/parsers/src/gsapWriter.parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -55,6 +56,7 @@ import {
resizeKeyframedTweenInScript as resizeKeyframedTweenAcorn,
addAnimationWithKeyframesToScript as addWithKfAcorn,
removeAnimationFromScript as removeAnimAcorn,
updateAnimationInScript as updateAnimAcorn,
shiftPositionsInScript as shiftAcorn,
scalePositionsInScript as scaleAcorn,
retimeClipTweensInScript,
Expand Down Expand Up @@ -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 = `
Expand Down
Loading
Loading