mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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); }
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user