fix(parsers): unroll keeps tween order and label positions the parser read (#4157)

* test(parsers): pin unroll order and side-effect soundness

* fix(parsers): resolve helper tweens in source order and unroll only pure tween helpers

* fix(parsers): unroll keeps a helper whose tween chain ends in a non-tween call

A helper like tl.to(...).call(fn) was treated as adding only tweens, so unroll
dropped the callback. Only chains of set/to/from/fromTo count now.
Also reuses isFunctionNode and names the unroll scope.

* fix(parsers): keep chain order, label positions and chain callbacks when unrolling

Review found three ways unroll still changed a timeline:
- a helper call positioned by a label was read at 0 (label order used raw loc)
- a tween chain added by a helper ran in reverse (outer call stamped first)
- an inner chain link with a callback was unrolled and lost the callback

* fix(parsers): resolve a label defined inside an inlined helper at its call site

addLabel was never tagged during helper expansion, so a cloned label kept
its declaration-body offset while its sibling tweens were remapped to the
call site. Comparing the two put every such label before every tween.
addLabel now gets the same site/order stamp, with order as the tie-break
when a label and its own tween share a call site.

* test(parsers): assert the sibling-label unroll rewrite matches its parsed start

The only literal-rewrite path this fix actually exercises (the label-defining
helper itself is never unrolled, since onlyAddsTweens rejects addLabel).

* refactor(parsers): unroll's effect scan reuses acorn-walk like its sibling

containsEffect hand-rolled a generic object walk; hasUnsafeLoopIndexUse in
gsapWriterAcorn.ts already solves the same shape with acorn-walk.full.
This commit is contained in:
Miguel Ángel
2026-09-19 02:47:20 -04:00
committed by GitHub
parent 752b552789
commit 559e57d295
4 changed files with 260 additions and 35 deletions
+39 -8
View File
@@ -20,7 +20,16 @@ import type { GsapProvenance } from "./gsapSerialize.js";
type Node = any;
/** Node keys that are metadata, not child AST to traverse/substitute. */
const SKIP_KEYS = new Set(["type", "start", "end", "loc", "range", "__hfProvenance", "__hfOrder"]);
const SKIP_KEYS = new Set([
"type",
"start",
"end",
"loc",
"range",
"__hfProvenance",
"__hfOrder",
"__hfSiteStart",
]);
const FUNCTION_TYPES = new Set([
"ArrowFunctionExpression",
@@ -33,7 +42,7 @@ const GSAP_METHODS = new Set(["set", "to", "from", "fromTo"]);
const MAX_DEPTH = 8;
const MAX_ITERS = 512;
function isFunctionNode(node: Node): boolean {
export function isFunctionNode(node: Node): boolean {
return !!node && FUNCTION_TYPES.has(node.type);
}
@@ -161,6 +170,8 @@ interface ExpandCtx {
site: { n: number };
/** Mutable counter stamping expansion order onto tweens (clones share source loc). */
order: { n: number };
/** Source offset of the top-level statement being expanded; tweens inherit it as their sort site. */
rootStart: number;
}
function walkNodes(node: Node, fn: (n: Node) => void): void {
@@ -181,13 +192,20 @@ function timelineRootName(call: Node): string | null {
return obj?.type === "Identifier" ? obj.name : null;
}
function isTimelineRooted(call: Node, timelineVar: string): boolean {
export function isTimelineRooted(call: Node, timelineVar: string): boolean {
if (timelineRootName(call) !== timelineVar) return false;
return (
call.callee?.property?.type === "Identifier" && GSAP_METHODS.has(call.callee.property.name)
);
}
/** True for `tl.addLabel(...)`: not a tween, but its position must sort in the same coordinate
* space as tween calls, or a label defined inside an inlined helper resolves at its declaration
* offset instead of its call site. */
function isAddLabelCall(call: Node, timelineVar: string): boolean {
return timelineRootName(call) === timelineVar && call.callee?.property?.name === "addLabel";
}
function containsTimelineCall(node: Node, timelineVar: string): boolean {
let found = false;
walkNodes(node, (n) => {
@@ -463,12 +481,21 @@ function bodyStatements(node: Node): Node[] {
/** Tag this body's direct timeline tweens with provenance + a monotonic expansion-order stamp. */
function tagTimelineCalls(stmts: Node[], prov: GsapProvenance, ctx: ExpandCtx): void {
for (const stmt of stmts) {
const calls: Node[] = [];
walkNodes(stmt, (n) => {
if (n.type === "CallExpression" && isTimelineRooted(n, ctx.timelineVar)) {
tagProvenance(n, { ...prov });
n.__hfOrder = ctx.order.n++;
}
if (
n.type === "CallExpression" &&
(isTimelineRooted(n, ctx.timelineVar) || isAddLabelCall(n, ctx.timelineVar))
)
calls.push(n);
});
// A chain's outer call is visited first but runs last; stamp in source order.
calls.sort((a, b) => a.callee.property.start - b.callee.property.start);
for (const n of calls) {
tagProvenance(n, { ...prov });
n.__hfOrder = ctx.order.n++;
n.__hfSiteStart = ctx.rootStart;
}
}
}
@@ -724,7 +751,11 @@ export function inlineComputedTimelines(
depth: 0,
site: { n: 0 },
order: { n: 0 },
rootStart: 0,
};
const body = (ast.body ?? []).filter((stmt: Node) => !isHelperDecl(stmt, helpers));
ast.body = expandStatements(body, ctx);
ast.body = body.flatMap((stmt: Node) => {
ctx.rootStart = stmt.start;
return expandStatements([stmt], ctx);
});
}
+34 -14
View File
@@ -1749,7 +1749,23 @@ function collectAddLabelDefs(
scope: ScopeBindings,
sortedCalls: TweenCallInfo[],
): AddLabelDef[] {
const callLocs = sortedCalls.map((c) => c.node.callee?.property?.loc?.start);
// Clones from the same expansion share __hfSiteStart (see tagTimelineCalls), so a
// label and a sibling tween can tie on site; __hfOrder is the tie-break, matching
// the source order tagTimelineCalls stamped them in.
const callKeys = sortedCalls.map((c) => ({
site: callSiteStart(c),
order: c.node.__hfOrder as number | undefined,
}));
const comesAfter = (
site: number | undefined,
order: number | undefined,
labelSite: number,
labelOrder: number | undefined,
): boolean => {
if (site === undefined) return false;
if (site !== labelSite) return site > labelSite;
return (order ?? -Infinity) > (labelOrder ?? -Infinity);
};
const defs: AddLabelDef[] = [];
acornWalk.simple(ast, {
// fallow-ignore-next-line complexity
@@ -1772,14 +1788,13 @@ function collectAddLabelDefs(
const posVal = resolveNode(node.arguments?.[1], scope);
const position =
typeof posVal === "number" || typeof posVal === "string" ? posVal : undefined;
const labelLoc = callee.property?.loc?.start;
// Same coordinate space as callSiteStart: a label cloned into an inlined
// helper keeps the declaration's own offset unless tagTimelineCalls stamped it.
const labelStart: number | undefined = node.__hfSiteStart ?? callee.property?.start;
const labelOrder: number | undefined = node.__hfOrder;
let order = sortedCalls.length;
if (labelLoc) {
order = callLocs.findIndex(
(l) =>
l &&
(l.line > labelLoc.line || (l.line === labelLoc.line && l.column > labelLoc.column)),
);
if (labelStart !== undefined) {
order = callKeys.findIndex((k) => comesAfter(k.site, k.order, labelStart, labelOrder));
if (order === -1) order = sortedCalls.length;
}
defs.push({ name, position, order });
@@ -1795,15 +1810,20 @@ function compareByLoc(a: TweenCallInfo, b: TweenCallInfo): number {
return aLoc.line - bLoc.line || aLoc.column - bLoc.column;
}
// Inlined tweens carry a monotonic __hfOrder (clones share source loc, so loc
// can't order them); they sort by that, after all literal (loc-ordered) tweens.
// Clones of an inlined helper share source loc, so an inlined tween sorts at the top-level
// statement that expanded it (__hfSiteStart), then by expansion order (__hfOrder).
function callSiteStart(call: TweenCallInfo): number | undefined {
return call.node.__hfSiteStart ?? call.node.callee?.property?.start;
}
function compareCallOrder(a: TweenCallInfo, b: TweenCallInfo): number {
const aSite = callSiteStart(a);
const bSite = callSiteStart(b);
if (aSite !== undefined && bSite !== undefined && aSite !== bSite) return aSite - bSite;
const ao = a.node.__hfOrder;
const bo = b.node.__hfOrder;
if (ao === undefined && bo === undefined) return compareByLoc(a, b);
if (ao === undefined) return -1;
if (bo === undefined) return 1;
return ao - bo;
if (ao !== undefined && bo !== undefined) return ao - bo;
return compareByLoc(a, b);
}
function sortBySourcePosition(calls: TweenCallInfo[]): void {
+89
View File
@@ -155,4 +155,93 @@ fade("#b", 2);
${call};`;
expect(unrollComputedTimeline(script)).not.toContain("function fade");
});
describe("keeps the timeline the parser read", () => {
const starts = (script: string) =>
Object.fromEntries(
parseGsapScriptAcorn(script).animations.map((a) => [a.targetSelector, a.resolvedStart]),
);
const script = `const tl = gsap.timeline();
function fade(s) { tl.to(s, { opacity: 0, duration: 1 }); }
fade("#a"); tl.to("#c", { x: 1, duration: 1 }); fade("#b");`;
it("resolves helper tweens in source order among literal tweens", () => {
expect(starts(script)).toEqual({ "#a": 0, "#c": 1, "#b": 2 });
});
it("unrolls to a script whose tweens start at the same times", () => {
expect(starts(unrollComputedTimeline(script))).toEqual(starts(script));
});
it("places helper tweens at the label they name, and unrolls to the same starts", () => {
const labelled = `const tl = gsap.timeline();
function fade(s, at) { tl.to(s, { opacity: 0, duration: 1 }, at); }
tl.addLabel("mid", 2);
fade("#a", "mid"); fade("#b", "mid+=0.5");
tl.to("#c", { x: 1, duration: 1 }, "mid");`;
expect(starts(labelled)).toEqual({ "#a": 2, "#b": 2.5, "#c": 2 });
expect(starts(unrollComputedTimeline(labelled))).toEqual(starts(labelled));
});
it("keeps the order of a tween chain a helper adds", () => {
const chained = `const tl = gsap.timeline();
function f() { tl.to("#a", { opacity: 0, duration: 1 }).to("#b", { x: 5, duration: 2 }).to("#c", { y: 7, duration: 3 }); }
f();`;
expect(starts(chained)).toEqual({ "#a": 0, "#b": 1, "#c": 3 });
expect(starts(unrollComputedTimeline(chained))).toEqual(starts(chained));
});
it("resolves a tween positioned by a label defined inside a helper body", () => {
const labelInHelper = `const tl = gsap.timeline();
function group(s) { tl.addLabel("mid"); tl.to(s, { opacity: 0, duration: 1 }); }
tl.to("#pre", { x: 1, duration: 1 });
group("#a");
tl.to("#post", { x: 2, duration: 1 }, "mid");`;
expect(starts(labelInHelper)).toEqual({ "#pre": 0, "#a": 1, "#post": 1 });
});
it("resolves a helper that only reads a label defined inside a sibling helper", () => {
const siblingLabel = `const tl = gsap.timeline();
function makeLabel(s) { tl.addLabel("mid"); tl.to(s, { opacity: 0, duration: 1 }); }
function useLabel(s) { tl.to(s, { x: 1, duration: 1 }, "mid"); }
tl.to("#pre", { y: 1, duration: 1 });
makeLabel("#a");
useLabel("#b");`;
expect(starts(siblingLabel)).toEqual({ "#pre": 0, "#a": 1, "#b": 1 });
// useLabel only adds a tween, so it unrolls to a literal at the label's resolved start.
expect(starts(unrollComputedTimeline(siblingLabel))).toEqual(starts(siblingLabel));
});
});
it("leaves a helper as authored when an inner link of its tween chain has a callback", () => {
const script = `const tl = gsap.timeline();
function f(s) { tl.to(s, { opacity: 0, duration: 1, onComplete: () => { window.hit++; } }).to(s, { x: 1, duration: 1 }); }
f("#a");`;
expect(unrollComputedTimeline(script)).toBe(script);
});
it("leaves a helper call as authored when the helper does more than add tweens", () => {
const script = `const tl = gsap.timeline();
function fade(sel, at) { window.count++; tl.to(sel, { opacity: 1, duration: 1 }, at); }
fade("#a", 1);`;
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); }`;
expect(unrollComputedTimeline(script)).toBe(script);
});
it.each([
["a tl.call callback", "tl.call(() => window.hit++, [], at);"],
["a label", 'tl.addLabel("mark", at);'],
["a call chained after a tween", "tl.to(sel, { opacity: 1, duration: 1 }, at).call(() => {});"],
])("leaves a helper as authored when it adds %s", (_case, body) => {
const script = `const tl = gsap.timeline();
function fade(sel, at) { ${body} }
fade("#a", 1);`;
expect(unrollComputedTimeline(script)).toBe(script);
});
});
+98 -13
View File
@@ -15,10 +15,12 @@
* inside other helpers) are left as-is rather than guessed at.
*/
import * as acorn from "acorn";
import * as acornWalk from "acorn-walk";
import MagicString from "magic-string";
import type { GsapAnimation } from "./gsapSerialize.js";
import { serializeValue as valueToCode, safeJsKey as safeKey } from "./gsapSerialize.js";
import { parseGsapScriptAcorn } from "./gsapParserAcorn.js";
import { isFunctionNode, isTimelineRooted } from "./gsapInline.js";
// acorn nodes are structurally untyped here.
type Node = any;
@@ -100,9 +102,7 @@ function declaresFunction(stmt: Node): boolean {
if (stmt.type === "FunctionDeclaration") return true;
return (
stmt.type === "VariableDeclaration" &&
(stmt.declarations ?? []).some((d: Node) =>
["FunctionExpression", "ArrowFunctionExpression"].includes(d.init?.type),
)
(stmt.declarations ?? []).some((d: Node) => isFunctionNode(d.init))
);
}
@@ -146,16 +146,98 @@ function unreferencedHelperDecls(
return dead;
}
/** A literal tween cannot encode an unknown start, duration or selector, so those statements stay as authored. */
function dropStatementsWithUnknownTiming(byStatement: Map<Node, GsapAnimation[]>): void {
for (const [stmt, anims] of byStatement) {
if (
anims.some(
(a) => a.durationUnresolved || a.resolvedStart === undefined || a.hasUnresolvedSelector,
)
) {
byStatement.delete(stmt);
const EFFECT_NODES = new Set([
"CallExpression",
"NewExpression",
"UpdateExpression",
"AssignmentExpression",
]);
function containsEffect(node: Node): boolean {
let hit = false;
acornWalk.full(node, (n: Node) => {
if (EFFECT_NODES.has(n.type)) hit = true;
});
return hit;
}
/** True for a chain like `tl.to(...).from(...)` where every link is a tween method with effect-free arguments. */
function isPureTweenChain(expr: Node, timelineVar: string): boolean {
let node = expr;
while (node?.type === "CallExpression") {
if (!isTimelineRooted(node, timelineVar) || node.arguments.some(containsEffect)) return false;
node = node.callee?.object;
}
return node?.type === "Identifier";
}
const blockStatements = (block: Node): Node[] =>
block?.type === "BlockStatement" ? block.body : block ? [block] : [];
/** Statements a `forEach` callback runs, or null when the call is not a `forEach` with a function. */
function forEachBody(call: Node): Node[] | null {
const callback = call.callee?.property?.name === "forEach" ? call.arguments[0] : null;
return callback?.body ? blockStatements(callback.body) : null;
}
/** Statements a helper call or a `forEach` callback runs, or null when the call is neither. */
function calledBody(call: Node, helpers: Map<string, Node[]>): Node[] | null {
if (call?.type !== "CallExpression") return null;
if (call.callee?.type !== "Identifier") return forEachBody(call);
return helpers.get(call.callee.name) ?? null;
}
/** Statements of the helper a call statement invokes, or of the loop it is, else null. */
function bodyOf(stmt: Node, helpers: Map<string, Node[]>): Node[] | null {
if (stmt.type === "ExpressionStatement") return calledBody(stmt.expression, helpers);
return stmt.body ? blockStatements(stmt.body) : null;
}
interface UnrollScope {
timelineVar: string;
helpers: Map<string, Node[]>;
}
/** True when running `stmts` only adds tweens to the timeline, so replacing them with literals drops nothing. */
function onlyAddsTweens(stmts: Node[], ctx: UnrollScope, seen: Set<Node[]> = new Set()): boolean {
if (seen.has(stmts)) return false;
seen.add(stmts);
return stmts.every((stmt) => {
if (stmt.type === "VariableDeclaration") return !containsEffect(stmt);
if (stmt.type === "ExpressionStatement" && isPureTweenChain(stmt.expression, ctx.timelineVar)) {
return true;
}
const nested = bodyOf(stmt, ctx.helpers);
return nested !== null && onlyAddsTweens(nested, ctx, seen);
});
}
/** `[name, body]` for each function a top-level statement declares. */
function declaredFunctions(stmt: Node): Array<[string, Node[]]> {
if (stmt.type === "FunctionDeclaration") {
return stmt.id?.name ? [[stmt.id.name, blockStatements(stmt.body)]] : [];
}
if (stmt.type !== "VariableDeclaration") return [];
return stmt.declarations
.filter((d: Node) => d.id?.name && isFunctionNode(d.init))
.map((d: Node): [string, Node[]] => [d.id.name, blockStatements(d.init.body)]);
}
/** Function declarations and function-valued consts by name, with their body statements. */
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. */
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,
);
const bodyStmts = unknownTiming ? null : bodyOf(stmt, ctx.helpers);
if (bodyStmts === null || !onlyAddsTweens(bodyStmts, ctx)) byStatement.delete(stmt);
}
}
@@ -189,7 +271,10 @@ export function unrollComputedTimeline(script: string): string {
const grouped = groupByTopLevelStatement(computed, statements);
if (!grouped) return script;
const { byStatement, helperNames } = grouped;
dropStatementsWithUnknownTiming(byStatement);
dropStatementsUnsafeToUnroll(byStatement, {
timelineVar: parsed.timelineVar,
helpers: collectHelperBodies(statements),
});
if (byStatement.size === 0) return script;
const ms = new MagicString(script);