fix(parsers): preserve safe GSAP helper defaults (#2985)

* fix(parsers): preserve safe GSAP helper defaults

* fix(parsers): make helper inlining call-safe
This commit is contained in:
Miguel Ángel
2026-08-04 19:08:02 +00:00
committed by GitHub
parent f9ec93459f
commit 532f06158b
3 changed files with 281 additions and 21 deletions
+105
View File
@@ -103,7 +103,112 @@ const kinds = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.kind);
const sites = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.callSite);
const iters = (t: Array<{ prov: any }>): any[] => t.map((x) => x.prov?.iteration);
function hasFunctionDeclaration(ast: any): boolean {
return ast.body.some((node: any) => node.type === "FunctionDeclaration");
}
function expectInlinedPosition(code: string, position: Record<string, unknown>): void {
const { ast, tweens } = run(code);
expect(hasFunctionDeclaration(ast)).toBe(false);
expect(tweens).toHaveLength(1);
expect(tweens[0]!.pos).toMatchObject(position);
}
function expectHelperPreserved(code: string): void {
const { ast, tweens } = run(code);
expect(hasFunctionDeclaration(ast)).toBe(true);
expect(tweens).toHaveLength(1);
expect(tweens[0]!.prov).toBeUndefined();
}
describe("inlineComputedTimelines — helpers", () => {
it("binds omitted and explicit undefined arguments through identifier defaults", () => {
const { tweens } = run(`const tl=gsap.timeline();
function slam(selector, at, opts = {}) { tl.to(selector, opts, at); }
slam("#a", 1);
slam("#b", 2, undefined);`);
expect(tweens).toHaveLength(2);
expect(tweens.map((t) => t.pos.value)).toEqual([1, 2]);
expect(kinds(tweens)).toEqual(["helper", "helper"]);
});
it("treats void 0 as undefined for default binding", () => {
const { tweens } = run(`const tl=gsap.timeline();
function slam(selector, at = 7) { tl.to(selector, {}, at); }
slam("#a", void 0);`);
expect(tweens).toHaveLength(1);
expect(tweens[0]!.pos).toMatchObject({ type: "Literal", value: 7 });
});
it("binds omitted required parameters before resolving later defaults", () => {
expectInlinedPosition(
`const tl=gsap.timeline();
function slam(selector, at, end = at) { tl.to(selector, {}, end); }
slam("#a");`,
{ type: "Identifier", name: "undefined" },
);
});
it("keeps null as the explicit argument instead of applying the default", () => {
const { ast } = run(`const tl=gsap.timeline();
function slam(selector, at, opts = {}) { tl.to(selector, opts, at); }
slam("#a", 1, null);`);
let vars: any;
simple(ast, {
CallExpression(n: any) {
if (tlMethod(n, "tl") === "to") vars = n.arguments[1];
},
});
expect(vars).toMatchObject({ type: "Literal", value: null });
});
it("resolves a default that only references an earlier bound parameter", () => {
const { tweens } = run(`const tl=gsap.timeline();
function slam(selector, at, end = at) { tl.to(selector, {}, end); }
slam("#a", 3);`);
expect(tweens[0]!.pos).toMatchObject({ type: "Literal", value: 3 });
});
it("does not treat object keys as unresolved value identifiers", () => {
expectInlinedPosition(
`const tl=gsap.timeline();
function slam(selector, at, opts = { at: at }) { tl.to(selector, opts, at); }
slam("#a", 3);`,
{ type: "Literal", value: 3 },
);
});
it("leaves helpers with effectful or forward-reference defaults uninlined", () => {
const effectful = run(`const tl=gsap.timeline();
function slam(selector, at = Date.now()) { tl.to(selector, {}, at); }
slam("#a");`);
expect(effectful.ast.body.some((s: any) => s.type === "FunctionDeclaration")).toBe(true);
const forward = run(`const tl=gsap.timeline();
function slam(selector = later, later = "#a") { tl.to(selector, {}, 0); }
slam();`);
expect(forward.ast.body.some((s: any) => s.type === "FunctionDeclaration")).toBe(true);
});
it.each([
["call", "makeOptions()"],
["constructor", "new Options()"],
["assignment", "(seed = 2)"],
["sequence", "(seed, 2)"],
["ambient member", "Math.PI"],
])("leaves a helper with an unsafe %s default uninlined", (_label, unsafeDefault) => {
expectHelperPreserved(`const tl=gsap.timeline();
function slam(selector, seed, at = ${unsafeDefault}) { tl.to(selector, {}, at); }
slam("#a", 1);`);
});
it("keeps a helper declaration when any call uses spread arguments", () => {
expectHelperPreserved(`const tl=gsap.timeline();
function slam(selector, at = 0) { tl.to(selector, {}, at); }
const args = ["#a", 2];
slam(...args);`);
});
it("expands a helper called N times, substituting positions per call", () => {
const { tweens } = run(`const tl=gsap.timeline();
function addCycle(at){ tl.to("#p", {}, at + 0.3); }
+156 -21
View File
@@ -202,13 +202,102 @@ function rangeOf(node: Node): [number, number] | undefined {
: undefined;
}
/** Plain identifier params + block body (shape we can inline). Timeline content checked separately. */
interface SupportedParam {
name: string;
defaultExpression?: Node;
}
const SAFE_DEFAULT_NODES = new Set([
"ArrayExpression",
"BinaryExpression",
"ChainExpression",
"ConditionalExpression",
"Identifier",
"Literal",
"LogicalExpression",
"MemberExpression",
"ObjectExpression",
"Property",
"SpreadElement",
"TemplateElement",
"TemplateLiteral",
"UnaryExpression",
]);
// ponytail: This allowlist is intentionally load-bearing. Defaults are evaluated
// while deciding whether a helper declaration can be erased, so admitting calls,
// assignments, updates, or constructors here could execute author code at a
// different time (or more than once). Add syntax only with negative-path tests.
/**
* A default expression is safe only when evaluating it cannot execute author
* code and every value identifier refers to an earlier parameter. This mirrors
* JavaScript's left-to-right default binding while keeping the static parser
* deliberately narrower than a JavaScript interpreter.
*/
function isSafeDefaultExpression(node: Node, earlierParams: ReadonlySet<string>): boolean {
let safe = true;
// fallow-ignore-next-line complexity
const visit = (current: Node, parent?: Node, key?: string): void => {
if (!isNode(current) || !safe) return;
if (!SAFE_DEFAULT_NODES.has(current.type)) {
safe = false;
return;
}
if (current.type === "UnaryExpression" && current.operator === "delete") {
safe = false;
return;
}
if (current.type === "Identifier") {
const nonValue = parent && key ? isNonValueIdentifierSlot(parent, key) : false;
if (!nonValue && current.name !== "undefined" && !earlierParams.has(current.name)) {
safe = false;
}
return;
}
for (const childKey of Object.keys(current)) {
if (SKIP_KEYS.has(childKey)) continue;
const child = current[childKey];
if (Array.isArray(child)) {
for (const item of child) visit(item, current, childKey);
} else {
visit(child, current, childKey);
}
}
};
visit(node);
return safe;
}
const SUPPORTED_PARAMS_CACHE = new WeakMap<object, SupportedParam[] | null>();
function supportedParam(param: Node, earlier: ReadonlySet<string>): SupportedParam | null {
if (param.type === "Identifier") return { name: param.name };
if (param.type !== "AssignmentPattern" || param.left?.type !== "Identifier") return null;
if (!isSafeDefaultExpression(param.right, earlier)) return null;
return { name: param.left.name, defaultExpression: param.right };
}
function supportedParams(fn: Node): SupportedParam[] | null {
if (SUPPORTED_PARAMS_CACHE.has(fn)) return SUPPORTED_PARAMS_CACHE.get(fn) ?? null;
const params: SupportedParam[] = [];
const earlier = new Set<string>();
for (const param of fn.params ?? []) {
const parsed = supportedParam(param, earlier);
if (!parsed) {
SUPPORTED_PARAMS_CACHE.set(fn, null);
return null;
}
params.push(parsed);
earlier.add(parsed.name);
}
SUPPORTED_PARAMS_CACHE.set(fn, params);
return params;
}
/** Identifier/default params + block body (shape we can inline). Timeline content checked separately. */
function isShapeEligible(fn: Node): boolean {
return (
isFunctionNode(fn) &&
fn.body?.type === "BlockStatement" &&
!(fn.params ?? []).some((p: Node) => p.type !== "Identifier")
);
return isFunctionNode(fn) && fn.body?.type === "BlockStatement" && supportedParams(fn) !== null;
}
/** True if the subtree calls any function named in `names`. */
@@ -274,6 +363,51 @@ function bump(counts: Map<string, number>, key: string): void {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
function undefinedIdentifier(): Node {
return { type: "Identifier", name: "undefined" };
}
function isExplicitUndefined(node: Node | undefined): boolean {
return (
(node?.type === "Identifier" && node.name === "undefined") ||
(node?.type === "UnaryExpression" &&
node.operator === "void" &&
node.argument?.type === "Literal" &&
node.argument.value === 0)
);
}
/** Resolve one call exactly as JavaScript binds identifier/default parameters. */
function resolveHelperBindings(call: Node, params: SupportedParam[]): Map<string, Node> | null {
if (call.arguments?.some((arg: Node) => arg?.type === "SpreadElement")) return null;
const bindings = new Map<string, Node>();
for (let i = 0; i < params.length; i++) {
const param = params[i]!;
const arg = call.arguments?.[i];
if (arg && !isExplicitUndefined(arg)) {
bindings.set(param.name, arg);
} else if (param.defaultExpression) {
bindings.set(param.name, substituteParams(cloneNode(param.defaultExpression), bindings));
} else {
// Omitted required parameters are still bound by JavaScript. Keeping an
// explicit undefined node prevents a dropped helper declaration from
// leaving its parameter identifier dangling in the synthetic AST.
bindings.set(param.name, undefinedIdentifier());
}
}
return bindings;
}
function statementHelperCall(node: Node, names: ReadonlySet<string>): Node | undefined {
if (node.type !== "ExpressionStatement") return undefined;
const expression = node.expression;
if (expression?.type !== "CallExpression" || expression.callee?.type !== "Identifier") {
return undefined;
}
return names.has(expression.callee.name) ? expression : undefined;
}
/**
* Keep only candidates safe to drop: every reference to the name is its
* declaration or a statement-level call. (1 decl id + 1 callee id per
@@ -283,20 +417,21 @@ function safelyDroppable(program: Node, candidates: Map<string, Node>): Map<stri
const names = new Set(candidates.keys());
const totalIds = new Map<string, number>();
const stmtCalls = new Map<string, number>();
const unbindable = new Set<string>();
walkNodes(program, (n) => {
if (n.type === "Identifier" && names.has(n.name)) bump(totalIds, n.name);
const e = n.type === "ExpressionStatement" ? n.expression : undefined;
if (
e?.type === "CallExpression" &&
e.callee?.type === "Identifier" &&
names.has(e.callee.name)
) {
bump(stmtCalls, e.callee.name);
}
const call = statementHelperCall(n, names);
if (!call) return;
bump(stmtCalls, call.callee.name);
const fn = candidates.get(call.callee.name);
const params = fn && supportedParams(fn);
if (!params || !resolveHelperBindings(call, params)) unbindable.add(call.callee.name);
});
const safe = new Map<string, Node>();
for (const [name, fn] of candidates) {
if ((totalIds.get(name) ?? 0) === 1 + (stmtCalls.get(name) ?? 0)) safe.set(name, fn);
if (!unbindable.has(name) && (totalIds.get(name) ?? 0) === 1 + (stmtCalls.get(name) ?? 0)) {
safe.set(name, fn);
}
}
return safe;
}
@@ -354,13 +489,13 @@ function expandBody(
return [block];
}
function inlineHelper(call: Node, ctx: ExpandCtx): Node[] {
function inlineHelper(call: Node, ctx: ExpandCtx): Node[] | null {
const fn = ctx.helpers.get(call.callee.name);
const bindings = new Map<string, Node>();
(fn.params ?? []).forEach((p: Node, i: number) => {
const arg = call.arguments?.[i];
if (arg) bindings.set(p.name, arg);
});
if (!fn) return null;
const params = supportedParams(fn);
if (!params) return null;
const bindings = resolveHelperBindings(call, params);
if (!bindings) return null;
const prov: GsapProvenance = {
kind: "helper",
fn: call.callee.name,
@@ -28,6 +28,26 @@ function expectDistinctProxyIdentities(script: string): void {
}
describe("parseGsapScriptAcorn — computed timelines", () => {
it("parses the common helper default-parameter shape with JS default semantics", () => {
const script = `
const tl = gsap.timeline();
function slam(selector, at, opts = {}) {
tl.from(selector, { y: opts.y, duration: 0.4 }, at);
}
slam("#a", 1, { y: 18 });
slam("#b", 2, undefined);
slam("#c", 3, null);
`;
const { animations } = parseGsapScriptAcorn(script);
expect(animations.map((animation) => animation.targetSelector)).toEqual(["#a", "#b", "#c"]);
expect(animations.map((animation) => animation.resolvedStart)).toEqual([1, 2, 3]);
expect(animations.map((animation) => animation.provenance?.kind)).toEqual([
"helper",
"helper",
"helper",
]);
});
it("resolves an add-to-basket helper called twice (the reported case)", () => {
const script = `
const tl = gsap.timeline({ paused: true });