From 6f4c6308f01b5a4471c56833d291ad9bc2a02950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 20:53:24 -0400 Subject: [PATCH] fix(lint): isolate IIFE timing constants (#2378) --- packages/lint/src/rules/gsap.test.ts | 20 ++++++++++++++++ .../parsers/src/gsapParserAcorn.full.test.ts | 13 +++++++++++ packages/parsers/src/gsapParserAcorn.ts | 23 +++++++++++++++++-- 3 files changed, 54 insertions(+), 2 deletions(-) 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;