diff --git a/packages/parsers/src/gsapInline.test.ts b/packages/parsers/src/gsapInline.test.ts index 9d88b7ce6..19cb42e5d 100644 --- a/packages/parsers/src/gsapInline.test.ts +++ b/packages/parsers/src/gsapInline.test.ts @@ -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): 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); } diff --git a/packages/parsers/src/gsapInline.ts b/packages/parsers/src/gsapInline.ts index 5989fc35c..411e0d66b 100644 --- a/packages/parsers/src/gsapInline.ts +++ b/packages/parsers/src/gsapInline.ts @@ -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): 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(); + +function supportedParam(param: Node, earlier: ReadonlySet): 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(); + 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, 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 | null { + if (call.arguments?.some((arg: Node) => arg?.type === "SpreadElement")) return null; + + const bindings = new Map(); + 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): 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): Map(); const stmtCalls = new Map(); + const unbindable = new Set(); 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(); 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(); - (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, diff --git a/packages/parsers/src/gsapParserAcorn.computed.test.ts b/packages/parsers/src/gsapParserAcorn.computed.test.ts index 9666c4a2f..08846d621 100644 --- a/packages/parsers/src/gsapParserAcorn.computed.test.ts +++ b/packages/parsers/src/gsapParserAcorn.computed.test.ts @@ -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 });