diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts
index cfefe769c..5f9c93fcc 100644
--- a/packages/lint/src/rules/gsap.test.ts
+++ b/packages/lint/src/rules/gsap.test.ts
@@ -928,6 +928,26 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
+ it("does NOT report overlapping_gsap_tweens for same-named constants in separate IIFEs", async () => {
+ const html = `
+
+
+
+
+`;
+ const result = await lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
+ expect(finding).toBeUndefined();
+ });
+
it("detects overlapping_gsap_tweens between variable-target tweens", async () => {
// Both tweens target the same element via a querySelector variable and their
// windows overlap on `opacity`. The structure-driven window builder must see
diff --git a/packages/parsers/src/gsapParserAcorn.full.test.ts b/packages/parsers/src/gsapParserAcorn.full.test.ts
index cc686e11c..eca4213a0 100644
--- a/packages/parsers/src/gsapParserAcorn.full.test.ts
+++ b/packages/parsers/src/gsapParserAcorn.full.test.ts
@@ -685,6 +685,19 @@ describe("variable-target resolution (querySelector pattern)", () => {
expect(result.animations[2].extras?.stagger).toBe("__raw:0.1");
});
+ it("does not leak same-named constants across IIFE scopes", () => {
+ const script = `
+ const tl = gsap.timeline({ paused: true });
+ (() => { const T = 0; tl.to("#x", { opacity: 1, duration: 1 }, T + 0); })();
+ (() => { const T = 10; tl.to("#x", { opacity: 0, duration: 1 }, T + 0); })();
+ `;
+ const result = parseGsapScript(script);
+ expect(result.animations.map((animation) => animation.position)).toEqual([
+ "__raw:T + 0",
+ "__raw:T + 0",
+ ]);
+ });
+
it("marks unresolvable variable targets with __unresolved__ and hasUnresolvedSelector", () => {
const script = `
const tl = gsap.timeline({ paused: true });
diff --git a/packages/parsers/src/gsapParserAcorn.ts b/packages/parsers/src/gsapParserAcorn.ts
index a8536132b..048e0befd 100644
--- a/packages/parsers/src/gsapParserAcorn.ts
+++ b/packages/parsers/src/gsapParserAcorn.ts
@@ -296,6 +296,12 @@ function resolveCollectionSelector(
function collectScopeBindings(ast: any): ScopeBindings {
const bindings = new Map();
+ // This compact resolver is intentionally conservative: it does not carry a
+ // full lexical environment. If the same identifier has different constant
+ // values in separate function/IIFE scopes, treating either value as global
+ // corrupts every tween in the other scope. Mark that name ambiguous so its
+ // expressions stay __raw and timing-sensitive lint rules skip them.
+ const ambiguousBindings = new Set();
// Const ARRAY/OBJECT literals are kept as AST nodes for member/index folding
// (resolveMemberNode), exposed to resolveNode via the CONST_NODES side-table.
const constNodes: ConstNodes = new Map();
@@ -310,7 +316,14 @@ function collectScopeBindings(ast: any): ScopeBindings {
return;
}
const val = resolveNode(init, bindings);
- if (val !== undefined) bindings.set(name, val);
+ if (val === undefined || ambiguousBindings.has(name)) return;
+ const existing = bindings.get(name);
+ if (existing !== undefined && existing !== val) {
+ bindings.delete(name);
+ ambiguousBindings.add(name);
+ } else if (existing === undefined) {
+ bindings.set(name, val);
+ }
},
});
return bindings;
@@ -1171,7 +1184,13 @@ function tweenCallToAnimation(
const hasPositionArg = !!call.positionArg;
const posVal = hasPositionArg ? extractLiteralValue(call.positionArg, scope) : 0;
const position: number | string =
- typeof posVal === "number" ? posVal : typeof posVal === "string" ? posVal : 0;
+ typeof posVal === "number"
+ ? posVal
+ : typeof posVal === "string"
+ ? posVal
+ : hasPositionArg
+ ? `__raw:${source.slice(call.positionArg.start, call.positionArg.end)}`
+ : 0;
let duration = typeof vars.duration === "number" ? vars.duration : undefined;
const ease = typeof vars.ease === "string" ? vars.ease : undefined;