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
105 changes: 105 additions & 0 deletions packages/parsers/src/gsapInline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
miguel-heygen marked this conversation as resolved.

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); }
Expand Down
177 changes: 156 additions & 21 deletions packages/parsers/src/gsapInline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Comment thread
miguel-heygen marked this conversation as resolved.
"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`. */
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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,
Comment thread
miguel-heygen marked this conversation as resolved.
Expand Down
20 changes: 20 additions & 0 deletions packages/parsers/src/gsapParserAcorn.computed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading