fix(parsers): emit global gsap.set position holds before the timeline declaration

A base `gsap.set(...)` written AFTER the tween calls is wiped on the next
soft reload: when a `from()` tween on the same target lazily initializes
during a backwards render (the studio rebind's progress(0.0001) kick), GSAP
reverts its internal isFromStart set, which removes the whole inline
`transform` — taking the base set's x/y with it. The from() tween then
re-parses the computed transform as identity and bakes x/y = 0 into the
GSAP cache, so every element previously moved in the studio snaps back to
its authored position whenever any other element is edited.

Emitting the global set BEFORE the timeline construction makes it part of
the pre-tween state the from() records, so every revert restores the moved
pose instead of stripping it.

- addAnimationToScript: global sets insert above the timeline declaration;
  the new-id lookup now diffs content-based ids instead of assuming the
  appended statement is last in source order.
- updateAnimationInScript: a legacy trailing global set is relocated above
  the declaration whenever it's touched, healing files written before this
  change on the next nudge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 01:08:46 -07:00
co-authored by Claude Fable 5
parent 4a0091f160
commit e0b5d01c6d
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 };
}