From 90d77d016ffb75264491bfebb4c227072904d63d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 11 Jul 2026 04:08:04 -0400 Subject: [PATCH] fix(parsers): never emit duplicate vars keys in either writer A parsed timeline set carries immediateRender in extras; the recast statement builder ALSO pushed the flag unconditionally, so every split/re-add of a set wrote 'immediateRender: true, immediateRender: true' into the file, doubling on each pass. Both writer twins (recast + acorn) now emit each vars key exactly once, properties winning over extras, and reconcileEditableProps skips newProps keys it already preserved. --- packages/parsers/src/gsapParser.test.ts | 21 ++++++++++++++ packages/parsers/src/gsapParser.ts | 16 ++++++++-- .../src/gsapWriterAcorn.inline.test.ts | 29 +++++++++++++++++++ packages/parsers/src/gsapWriterAcorn.ts | 14 +++++++-- 4 files changed, 75 insertions(+), 5 deletions(-) diff --git a/packages/parsers/src/gsapParser.test.ts b/packages/parsers/src/gsapParser.test.ts index 413fc5399..11b592f9a 100644 --- a/packages/parsers/src/gsapParser.test.ts +++ b/packages/parsers/src/gsapParser.test.ts @@ -3026,3 +3026,24 @@ describe("single position write per element (consolidation)", () => { expect(parseGsapScript(out).animations.some((a) => "opacity" in a.properties)).toBe(true); }); }); + +describe("recast writer never doubles vars keys", () => { + // Regression: buildTweenStatementCode pushed `immediateRender: true` for + // every timeline set AND appended extras — a parsed set carries the flag in + // extras, so splitting a mixed set emitted + // `immediateRender: true, immediateRender: true` into the file. + const src = `window.__timelines = window.__timelines || {}; +const tl = gsap.timeline({ paused: true }); +tl.set("#a", { z: 0, rotationX: 5, immediateRender: true, scale: 1 }, 0); +window.__timelines["main"] = tl;`; + + it("split of a mixed set emits the flag once per group", () => { + const id = parseGsapScript(src).animations[0]!.id; + const { script: out } = splitIntoPropertyGroups(src, id); + const setLines = out.split("\n").filter((l) => l.includes("tl.set(")); + expect(setLines.length).toBeGreaterThan(1); + for (const line of setLines) { + expect(line.match(/immediateRender/g) ?? []).toHaveLength(1); + } + }); +}); diff --git a/packages/parsers/src/gsapParser.ts b/packages/parsers/src/gsapParser.ts index 4272ec060..315c238c8 100644 --- a/packages/parsers/src/gsapParser.ts +++ b/packages/parsers/src/gsapParser.ts @@ -1387,13 +1387,25 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit `${safeKey(k)}: ${valueToCode(v)}`); + const emitted = new Set(Object.keys(props)); // immediateRender forces GSAP to apply the set when added to the timeline, // not on the first seek — without it, tl.set at position 0 on a paused // timeline is invisible until the playhead moves past 0. A base `gsap.set` - // already runs immediately, so it doesn't need (or get) the flag. - if (anim.method === "set" && !anim.global) entries.push("immediateRender: true"); + // already runs immediately, so it doesn't need (or get) the flag. A parsed + // set carries the flag in extras — never emit the same key twice. + if ( + anim.method === "set" && + !anim.global && + !emitted.has("immediateRender") && + !(anim.extras && "immediateRender" in anim.extras) + ) { + entries.push("immediateRender: true"); + emitted.add("immediateRender"); + } if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { + if (emitted.has(k)) continue; + emitted.add(k); entries.push(`${safeKey(k)}: ${valueToCode(v as number | string)}`); } } diff --git a/packages/parsers/src/gsapWriterAcorn.inline.test.ts b/packages/parsers/src/gsapWriterAcorn.inline.test.ts index 4eaa4ca22..30b165242 100644 --- a/packages/parsers/src/gsapWriterAcorn.inline.test.ts +++ b/packages/parsers/src/gsapWriterAcorn.inline.test.ts @@ -101,3 +101,32 @@ window.__timelines["scene"] = gsap.timeline({ paused: true });`; expect(parseGsapScriptAcorn(out).animations).toHaveLength(2); }); }); + +describe("no duplicate vars keys on rewrite", () => { + const setSrc = `window.__timelines = window.__timelines || {}; +const tl = gsap.timeline({ paused: true }); +tl.set("#a", { z: 0, rotationX: 5, immediateRender: true }, 0); +window.__timelines["main"] = tl;`; + + it("update with immediateRender riding in newProps emits the key once", () => { + const id = parseGsapScriptAcorn(setSrc).animations[0]!.id; + // Studio paths that build props off live tween vars carry the flag along. + const out = updateAnimationInScript(setSrc, id, { + properties: { z: 0, rotationX: 9, immediateRender: "__raw:true" }, + }); + expect(out).toContain("rotationX: 9"); + expect(out.match(/immediateRender/g)).toHaveLength(1); + }); + + it("addAnimationToScript dedupes a key present in both properties and extras", () => { + const { script: out } = addAnimationToScript(setSrc, { + targetSelector: "#b", + method: "set", + position: 0, + properties: { scale: 1, immediateRender: "__raw:true" }, + extras: { immediateRender: "__raw:true" }, + }); + const setB = out.split("\n").find((l) => l.includes('"#b"')); + expect(setB?.match(/immediateRender/g)).toHaveLength(1); + }); +}); diff --git a/packages/parsers/src/gsapWriterAcorn.ts b/packages/parsers/src/gsapWriterAcorn.ts index 29eabebef..a0381e324 100644 --- a/packages/parsers/src/gsapWriterAcorn.ts +++ b/packages/parsers/src/gsapWriterAcorn.ts @@ -59,7 +59,9 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit `${safeKey(k)}: ${valueToCode(v)}`); if (anim.extras) { for (const [k, v] of Object.entries(anim.extras)) { - entries.push(`${safeKey(k)}: ${valueToCode(v)}`); + // A key carried by both properties and extras (a set's parsed + // `immediateRender: true`) must emit once — properties win. + if (!(k in props)) entries.push(`${safeKey(k)}: ${valueToCode(v)}`); } } const objCode = `{ ${entries.join(", ")} }`; @@ -263,10 +265,16 @@ function reconcileEditableProps( const overrides = nonEditableOverrides ?? {}; const { entries, keys } = preservedEntries(objNode, source, isEditableVarKey, overrides); for (const [key, value] of Object.entries(overrides)) { - if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + if (!keys.has(key)) { + keys.add(key); + entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + } } for (const [key, value] of Object.entries(newProps)) { - entries.push(`${safeKey(key)}: ${valueToCode(value)}`); + // A non-editable key riding along in newProps (immediateRender read off + // live tween vars) is already preserved above — emitting it again writes + // `immediateRender: true, immediateRender: true` into the file. + if (!keys.has(key)) entries.push(`${safeKey(key)}: ${valueToCode(value)}`); } ms.overwrite(objNode.start, objNode.end, `{ ${entries.join(", ")} }`); }