Merge pull request #2099 from heygen-com/07-09-fix_parsers_emit_global_gsap.set_position_holds_before_the_timeline_declaration

fix(parsers): emit global gsap.set position holds before the timeline declaration
This commit is contained in:
Vance Ingalls
2026-07-09 09:52:21 -07:00
committed by GitHub
2 changed files with 123 additions and 5 deletions
@@ -224,6 +224,63 @@ describe("T6c — addAnimationToScript", () => {
// Inserted after timeline declaration
expect(result.indexOf('tl.to("#hero"')).toBeGreaterThan(result.indexOf("gsap.timeline"));
});
it("inserts a global gsap.set BEFORE the timeline declaration", () => {
// A base set emitted after the tween calls is wiped by GSAP's from()-init
// revert on the first backwards render (studio soft-reload rebind) — it
// must precede the timeline so the from() records it as pre-tween state.
const script = `\
var tl = gsap.timeline({ paused: true });
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
const { script: result, id } = addAnimationToScript(script, {
targetSelector: "#hero",
method: "set",
position: 0,
properties: { x: 267, y: 20 },
global: true,
});
expect(result).toContain('gsap.set("#hero", { x: 267, y: 20 });');
expect(result.indexOf('gsap.set("#hero"')).toBeLessThan(result.indexOf("gsap.timeline"));
expect(id).toBeTruthy();
// Round-trip: the id resolves back to the inserted set
const updated = updateAnimationInScript(result, id, { properties: { x: 300, y: 20 } });
expect(updated).toContain("x: 300");
});
});
// ---------------------------------------------------------------------------
// Legacy global-set relocation
// ---------------------------------------------------------------------------
describe("T6c — updateAnimationInScript relocates legacy trailing global set", () => {
const LEGACY = `\
var tl = gsap.timeline({ paused: true });
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
gsap.set("#hero", { x: 267, y: 20 });
window.__timelines["t"] = tl;`;
it("moves a post-timeline global set above the declaration when updated", () => {
const result = updateAnimationInScript(LEGACY, "#hero-set-0-position", {
properties: { x: 300, y: 20 },
});
expect(result).toContain("x: 300");
expect(result.indexOf("gsap.set(")).toBeLessThan(result.indexOf("gsap.timeline"));
expect(result).toContain('window.__timelines["t"] = tl;');
});
it("leaves an already-hoisted global set in place", () => {
const hoisted = `\
gsap.set("#hero", { x: 267, y: 20 });
var tl = gsap.timeline({ paused: true });
tl.from("#hero", { scale: 0.9, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;`;
const result = updateAnimationInScript(hoisted, "#hero-set-0-position", {
properties: { x: 300, y: 20 },
});
expect(result).toContain("x: 300");
expect(result.indexOf("gsap.set(")).toBeLessThan(result.indexOf("gsap.timeline"));
});
});
// ---------------------------------------------------------------------------
+66 -5
View File
@@ -301,6 +301,25 @@ function findInsertionPoint(parsed: ParsedGsapAcornForWrite): number | null {
return tlDecl?.end ?? (parsed.ast.end as number);
}
/**
* Line-start offset of the timeline declaration — where a global `gsap.set`
* must be inserted. A base set emitted AFTER the tween calls gets wiped on the
* next seek-driven rebuild: when a `from()` tween on the same target lazily
* initializes during a backwards render (the studio rebind's `progress(0.0001)`
* after a soft reload), GSAP reverts its internal isFromStart set, removing the
* whole inline `transform` — and the base set's x/y with it. Emitted BEFORE the
* timeline, the set is part of the pre-tween state the from() records, so every
* revert restores it instead. Returns null when no declaration exists.
*/
function findGlobalSetInsertionPoint(
parsed: ParsedGsapAcornForWrite,
script: string,
): number | null {
const tlDecl = findTimelineDeclarationStatement(parsed.ast, parsed.timelineVar);
if (!tlDecl) return null;
return script.lastIndexOf("\n", tlDecl.start) + 1;
}
// ── Public write API ─────────────────────────────────────────────────────────
// fallow-ignore-next-line complexity
@@ -370,6 +389,26 @@ export function updateAnimationInScript(
overwritePosition(ms, call, updates.position);
}
// Heal legacy scripts: a global `gsap.set` sitting after the timeline
// declaration is subject to the from()-init revert wipe (see
// findGlobalSetInsertionPoint) — relocate it above the declaration whenever
// it's touched, so the next nudge fixes files written before the reorder.
if (target.animation.method === "set" && target.animation.global) {
const globalSetPoint = findGlobalSetInsertionPoint(parsed, script);
const exprStmt = findEnclosingExpressionStatement(call.ancestors);
if (globalSetPoint !== null && exprStmt && exprStmt.start > globalSetPoint) {
const lineStart = script.lastIndexOf("\n", exprStmt.start) + 1;
const moveStart = /^\s*$/.test(script.slice(lineStart, exprStmt.start))
? lineStart
: exprStmt.start;
const moveEnd =
exprStmt.end < script.length && script[exprStmt.end] === "\n"
? exprStmt.end + 1
: exprStmt.end;
ms.move(moveStart, moveEnd, globalSetPoint);
}
}
return ms.toString();
}
@@ -455,16 +494,38 @@ export function addAnimationToScript(
const parsed = parseGsapScriptAcornForWrite(script);
if (!parsed) return { script, id: "" };
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return { script, id: "" };
const ms = new MagicString(script);
const statementCode = buildTweenStatementCode(parsed.timelineVar, animation);
ms.appendLeft(insertionPoint, "\n" + statementCode);
const globalSetPoint =
animation.method === "set" && animation.global
? findGlobalSetInsertionPoint(parsed, script)
: null;
if (globalSetPoint !== null) {
ms.appendLeft(globalSetPoint, statementCode + "\n");
} else {
const insertionPoint = findInsertionPoint(parsed);
if (insertionPoint === null) return { script, id: "" };
ms.appendLeft(insertionPoint, "\n" + statementCode);
}
const result = ms.toString();
const reParsed = parseGsapScriptAcornForWrite(result);
const newId = reParsed?.located[reParsed.located.length - 1]?.id ?? "";
// IDs are content-based, not positional — the new statement isn't necessarily
// the last located entry (a global set is inserted before the timeline).
// Diff against the pre-insert ids, counting duplicates.
const oldIdCounts = new Map<string, number>();
for (const entry of parsed.located) {
oldIdCounts.set(entry.id, (oldIdCounts.get(entry.id) ?? 0) + 1);
}
let newId = "";
for (const entry of reParsed?.located ?? []) {
const remaining = oldIdCounts.get(entry.id) ?? 0;
if (remaining === 0) {
newId = entry.id;
break;
}
oldIdCounts.set(entry.id, remaining - 1);
}
return { script: result, id: newId };
}