Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/parsers/src/gsapWriter.acorn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
});

// ---------------------------------------------------------------------------
Expand Down
71 changes: 66 additions & 5 deletions packages/parsers/src/gsapWriterAcorn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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 };
}

Expand Down
Loading