From e0b5d01c6d2e4e71e70c1cf46babe4a952a5e69c Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 9 Jul 2026 01:06:53 -0700 Subject: [PATCH] fix(parsers): emit global gsap.set position holds before the timeline declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/parsers/src/gsapWriter.acorn.test.ts | 57 +++++++++++++++ packages/parsers/src/gsapWriterAcorn.ts | 71 +++++++++++++++++-- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/packages/parsers/src/gsapWriter.acorn.test.ts b/packages/parsers/src/gsapWriter.acorn.test.ts index 1c40a9364..e7ddd41dc 100644 --- a/packages/parsers/src/gsapWriter.acorn.test.ts +++ b/packages/parsers/src/gsapWriter.acorn.test.ts @@ -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")); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 224f8522e..29eabebef 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -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(); + 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 }; }