fix(lint): isolate IIFE timing constants (#2378)

This commit is contained in:
Miguel Ángel
2026-07-13 20:53:24 -04:00
committed by GitHub
parent b231b6f963
commit 6f4c6308f0
3 changed files with 54 additions and 2 deletions
+20
View File
@@ -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 = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="x"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
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); })();
window.__timelines["c1"] = tl;
</script>
</body></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
@@ -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 });
+21 -2
View File
@@ -296,6 +296,12 @@ function resolveCollectionSelector(
function collectScopeBindings(ast: any): ScopeBindings {
const bindings = new Map<string, number | string | boolean>();
// 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<string>();
// 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;