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 }; }