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.
This commit is contained in:
Miguel Angel Simon Sierra
2026-07-11 04:08:04 -04:00
parent 066c3dae37
commit 90d77d016f
4 changed files with 75 additions and 5 deletions
+21
View File
@@ -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);
}
});
});
+14 -2
View File
@@ -1387,13 +1387,25 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
if (anim.method !== "set" && anim.duration !== undefined) props.duration = anim.duration;
if (anim.ease) props.ease = anim.ease;
const entries = Object.entries(props).map(([k, v]) => `${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)}`);
}
}
@@ -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);
});
});
+11 -3
View File
@@ -59,7 +59,9 @@ function buildTweenStatementCode(timelineVar: string, anim: Omit<GsapAnimation,
const entries = Object.entries(props).map(([k, v]) => `${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(", ")} }`);
}