From d6c4d1c04e93fa6bf0c01beadb447d7e624819ae Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Sat, 12 Sep 2026 04:45:14 +0000 Subject: [PATCH 1/3] fix(lint): stop gsap_repeated_fromto_without_baseline flagging a load-time gsap.set() baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its own fixHint recommended two fixes: `immediateRender: false`, or an earlier in-timeline `tl.set(sel, {...}, 0)` baseline. That second option collides with gsap_timeline_set_initial_hide, which independently warns on exactly that shape whenever the values hide the element — the common hide-until-reveal case both rules exist for. Following one rule's advice trips the other. gsap_timeline_set_initial_hide already treats a load-time `gsap.set(...)` as a reliable baseline (it exempts `global` sets outright), but gsap_repeated_fromto_without_baseline's own baseline check rejected any `global` set unconditionally. Aligns the two: a preceding load-time `gsap.set(...)` now satisfies the baseline check too, gated on a new reliability scan (factored out of the existing hidden-selector scan) that excludes a set deferred behind a callback or event handler, since the GSAP parser flags any bare `gsap.set(...)` as load-time regardless of where it sits in the AST. The fixHint now recommends the load-time route first and cross-references the other rule. Co-Authored-By: Miguel Ángel --- packages/lint/src/rules/gsap.test.ts | 65 ++++++++++++++++++++++++- packages/lint/src/rules/gsap.ts | 72 ++++++++++++++++++++-------- 2 files changed, 116 insertions(+), 21 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index e4a0798cd21..3eda4e3a9af 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -3101,7 +3101,11 @@ describe("SVG draw-on rules", () => { expect(finding).toBeUndefined(); }); - it("gsap_repeated_fromto_without_baseline: rejects an earlier standalone set", async () => { + it("gsap_repeated_fromto_without_baseline: accepts an earlier load-time standalone set", async () => { + // A load-time `gsap.set(...)` runs before the paused timeline's playhead + // ever moves, so it establishes the resting state just as reliably as an + // in-timeline `tl.set(..., 0)` — and unlike that shape, it can never trip + // gsap_timeline_set_initial_hide (which already exempts `global` sets). const html = `
@@ -3120,6 +3124,65 @@ describe("SVG draw-on rules", () => { (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", ); + expect(finding).toBeUndefined(); + }); + + it("gsap_repeated_fromto_without_baseline: a load-time set baseline also clears gsap_timeline_set_initial_hide", async () => { + // Regression: this rule's fixHint used to recommend an in-timeline + // `tl.set(sel, { ... }, 0)` baseline, which gsap_timeline_set_initial_hide + // then flags whenever those values hide the element — the hide-until-reveal + // shape both rules exist for. A load-time `gsap.set(...)` is the one + // baseline that satisfies both, so neither rule may fire on it. + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const codes = result.findings.map((finding) => finding.code); + + expect(codes).not.toContain("gsap_repeated_fromto_without_baseline"); + expect(codes).not.toContain("gsap_timeline_set_initial_hide"); + }); + + it("gsap_repeated_fromto_without_baseline: still rejects a standalone set deferred behind a callback", async () => { + // A `gsap.set(...)` inside an event handler only runs on user interaction, + // not at load, so it cannot stand in for a resting state before the first + // tween. This rejects on the old, blunter grounds too (any `global` set + // was previously excluded outright) — it's a regression guard against a + // future change that accepts `global` unconditionally and drops the + // extractStandaloneSetSelectors reliability check, not a red/green proof + // of that check by itself (see the load-time-baseline test above for that). + const html = ` + +
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", + ); + expect(finding?.severity).toBe("warning"); }); diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 215669de778..3f189f1d299 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -235,7 +235,7 @@ function isHiddenGsapState(values: Record): boolean { ); } -function hiddenSetTargetSelectors(target: string, aliases: Map): string[] { +function setTargetSelectors(target: string, aliases: Map): string[] { const parts = target.startsWith("[") && target.endsWith("]") ? target.slice(1, -1).split(",") : [target]; return parts @@ -248,7 +248,17 @@ function hiddenSetTargetSelectors(target: string, aliases: Map): .filter((selector) => selector.length > 0); } -function extractStandaloneHiddenSelectors(script: string): Set { +/** + * Selectors targeted by a load-time `gsap.set(...)`: one that runs as the + * script executes, not deferred behind a callback or event handler (IIFEs + * still count — they run at parse time). Callers that care about *what* the + * set applies pass `matchesValues` to filter on the object-literal source; + * callers that only need "is there a load-time set at all" omit it. + */ +function extractStandaloneSetSelectors( + script: string, + matchesValues?: (values: string) => boolean, +): Set { const selectors = new Set(); const source = stripJsComments(script); const functionRanges = collectFunctionBodyRanges(source); @@ -264,16 +274,20 @@ function extractStandaloneHiddenSelectors(script: string): Set { while ((match = pattern.exec(source)) !== null) { // Skip callback/handler bodies; keep IIFEs (they run at parse time). if (indexInsideNonIifeRange(match.index, source, functionRanges)) continue; - const targets = hiddenSetTargetSelectors((match[1] ?? "").trim(), aliases); + const targets = setTargetSelectors((match[1] ?? "").trim(), aliases); if (targets.length === 0) continue; - const body = match[2] ?? ""; - if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) { - for (const selector of targets) selectors.add(selector); - } + if (matchesValues && !matchesValues(match[2] ?? "")) continue; + for (const selector of targets) selectors.add(selector); } return selectors; } +function extractStandaloneHiddenSelectors(script: string): Set { + return extractStandaloneSetSelectors(script, (values) => + /(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(values), + ); +} + function oneValue( values: Record, keys: string[], @@ -1048,6 +1062,12 @@ export const gsapRules: LintRule[] = [ const authoredHiddenSelectors = new Set( scripts.flatMap((script) => [...extractStandaloneHiddenSelectors(script.content)]), ); + // Every selector with a load-time `gsap.set(...)`, whatever values it sets. + // gsap_repeated_fromto_without_baseline uses this below to tell a real + // load-time set apart from one buried in a callback. + const loadTimeSetSelectors = new Set( + scripts.flatMap((script) => [...extractStandaloneSetSelectors(script.content)]), + ); // Build clip element selector map type ClipInfo = { tag: string; id: string; classes: string }; @@ -1127,16 +1147,26 @@ export const gsapRules: LintRule[] = [ const selector = firstFromTo.targetSelector; const firstFromToIndex = gsapWindows.indexOf(firstFromTo); const firstFromToPosition = Math.min(...fromToWindows.map((win) => win.position)); - const hasTimelineBaseline = gsapWindows - .slice(0, firstFromToIndex) - .some( - (candidate) => - candidate.method === "set" && - !candidate.global && - candidate.targetSelector === selector && - candidate.position <= firstFromToPosition, - ); - if (hasTimelineBaseline) continue; + // A load-time `gsap.set(...)` runs before the paused timeline's playhead + // ever moves, so it establishes the resting state just as reliably as an + // in-timeline `tl.set(..., 0)` — and gsap_timeline_set_initial_hide + // already treats it that way (it exempts `win.global` outright). + // Accepting it here too stops the two rules from each demanding the + // shape the other flags. + const hasBaseline = gsapWindows.slice(0, firstFromToIndex).some((candidate) => { + if (candidate.method !== "set" || candidate.targetSelector !== selector) return false; + // The acorn parser flags `global` on any bare `gsap.set(...)` whatever + // its AST nesting, so one deferred behind a callback/handler is + // indistinguishable here; only the load-time scan separates them. + // Known gap: the scan reports selectors, not occurrences, so a second, + // genuinely load-time `gsap.set` for the same selector ANYWHERE in the + // composition (even after this candidate, even in another script) would + // also satisfy this check — accepted as narrow (requires duplicate + // `gsap.set` calls to one selector) rather than tracked per-occurrence. + if (candidate.global) return loadTimeSetSelectors.has(selector); + return candidate.position <= firstFromToPosition; + }); + if (hasBaseline) continue; findings.push({ code: "gsap_repeated_fromto_without_baseline", @@ -1148,9 +1178,11 @@ export const gsapRules: LintRule[] = [ `(immediateRender), not at tween position.`, selector, fixHint: - `Add \`immediateRender: false\` to the destination vars of each future fromTo, or set a safe ` + - `resting state with an earlier \`tl.set("${selector}", { ... }, 0)\`. Pre-first-tween seeks must not ` + - `inherit whichever fromTo call happened to author last.`, + `Add \`immediateRender: false\` to the destination vars of each future fromTo, or establish the ` + + `resting state with a load-time \`gsap.set("${selector}", { ... })\` before the first fromTo. ` + + `Prefer that over an in-timeline \`tl.set("${selector}", { ... }, 0)\`, which ` + + `gsap_timeline_set_initial_hide flags when those values hide the element. Pre-first-tween seeks ` + + `must not inherit whichever fromTo call happened to author last.`, snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n")), }); } From ebff022626bada13b536a6d8bfd618c7e43b6cf1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 03:04:27 -0700 Subject: [PATCH 2/3] fix(lint): keep the fromTo baseline check and point its hint at immediateRender: false --- packages/lint/src/rules/gsap.test.ts | 67 ++----------------------- packages/lint/src/rules/gsap.ts | 73 ++++++++-------------------- 2 files changed, 24 insertions(+), 116 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 3eda4e3a9af..c47b65d2684 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -3056,6 +3056,8 @@ describe("SVG draw-on rules", () => { expect(finding?.severity).toBe("warning"); expect(finding?.selector).toBe("#ring"); + expect(finding?.fixHint).toContain("immediateRender: false"); + expect(finding?.fixHint).toContain("gsap_timeline_set_initial_hide"); }); it("gsap_repeated_fromto_without_baseline: accepts explicit immediateRender false", async () => { @@ -3101,11 +3103,7 @@ describe("SVG draw-on rules", () => { expect(finding).toBeUndefined(); }); - it("gsap_repeated_fromto_without_baseline: accepts an earlier load-time standalone set", async () => { - // A load-time `gsap.set(...)` runs before the paused timeline's playhead - // ever moves, so it establishes the resting state just as reliably as an - // in-timeline `tl.set(..., 0)` — and unlike that shape, it can never trip - // gsap_timeline_set_initial_hide (which already exempts `global` sets). + it("gsap_repeated_fromto_without_baseline: rejects an earlier standalone set", async () => { const html = `
@@ -3124,65 +3122,6 @@ describe("SVG draw-on rules", () => { (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", ); - expect(finding).toBeUndefined(); - }); - - it("gsap_repeated_fromto_without_baseline: a load-time set baseline also clears gsap_timeline_set_initial_hide", async () => { - // Regression: this rule's fixHint used to recommend an in-timeline - // `tl.set(sel, { ... }, 0)` baseline, which gsap_timeline_set_initial_hide - // then flags whenever those values hide the element — the hide-until-reveal - // shape both rules exist for. A load-time `gsap.set(...)` is the one - // baseline that satisfies both, so neither rule may fire on it. - const html = ` - -
- - -`; - const result = await lintHyperframeHtml(html); - const codes = result.findings.map((finding) => finding.code); - - expect(codes).not.toContain("gsap_repeated_fromto_without_baseline"); - expect(codes).not.toContain("gsap_timeline_set_initial_hide"); - }); - - it("gsap_repeated_fromto_without_baseline: still rejects a standalone set deferred behind a callback", async () => { - // A `gsap.set(...)` inside an event handler only runs on user interaction, - // not at load, so it cannot stand in for a resting state before the first - // tween. This rejects on the old, blunter grounds too (any `global` set - // was previously excluded outright) — it's a regression guard against a - // future change that accepts `global` unconditionally and drops the - // extractStandaloneSetSelectors reliability check, not a red/green proof - // of that check by itself (see the load-time-baseline test above for that). - const html = ` - -
-
-
- - -`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find( - (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", - ); - expect(finding?.severity).toBe("warning"); }); diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 3f189f1d299..ee26e07db56 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -235,7 +235,7 @@ function isHiddenGsapState(values: Record): boolean { ); } -function setTargetSelectors(target: string, aliases: Map): string[] { +function hiddenSetTargetSelectors(target: string, aliases: Map): string[] { const parts = target.startsWith("[") && target.endsWith("]") ? target.slice(1, -1).split(",") : [target]; return parts @@ -248,17 +248,7 @@ function setTargetSelectors(target: string, aliases: Map): strin .filter((selector) => selector.length > 0); } -/** - * Selectors targeted by a load-time `gsap.set(...)`: one that runs as the - * script executes, not deferred behind a callback or event handler (IIFEs - * still count — they run at parse time). Callers that care about *what* the - * set applies pass `matchesValues` to filter on the object-literal source; - * callers that only need "is there a load-time set at all" omit it. - */ -function extractStandaloneSetSelectors( - script: string, - matchesValues?: (values: string) => boolean, -): Set { +function extractStandaloneHiddenSelectors(script: string): Set { const selectors = new Set(); const source = stripJsComments(script); const functionRanges = collectFunctionBodyRanges(source); @@ -274,20 +264,16 @@ function extractStandaloneSetSelectors( while ((match = pattern.exec(source)) !== null) { // Skip callback/handler bodies; keep IIFEs (they run at parse time). if (indexInsideNonIifeRange(match.index, source, functionRanges)) continue; - const targets = setTargetSelectors((match[1] ?? "").trim(), aliases); + const targets = hiddenSetTargetSelectors((match[1] ?? "").trim(), aliases); if (targets.length === 0) continue; - if (matchesValues && !matchesValues(match[2] ?? "")) continue; - for (const selector of targets) selectors.add(selector); + const body = match[2] ?? ""; + if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) { + for (const selector of targets) selectors.add(selector); + } } return selectors; } -function extractStandaloneHiddenSelectors(script: string): Set { - return extractStandaloneSetSelectors(script, (values) => - /(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(values), - ); -} - function oneValue( values: Record, keys: string[], @@ -1062,12 +1048,6 @@ export const gsapRules: LintRule[] = [ const authoredHiddenSelectors = new Set( scripts.flatMap((script) => [...extractStandaloneHiddenSelectors(script.content)]), ); - // Every selector with a load-time `gsap.set(...)`, whatever values it sets. - // gsap_repeated_fromto_without_baseline uses this below to tell a real - // load-time set apart from one buried in a callback. - const loadTimeSetSelectors = new Set( - scripts.flatMap((script) => [...extractStandaloneSetSelectors(script.content)]), - ); // Build clip element selector map type ClipInfo = { tag: string; id: string; classes: string }; @@ -1147,26 +1127,16 @@ export const gsapRules: LintRule[] = [ const selector = firstFromTo.targetSelector; const firstFromToIndex = gsapWindows.indexOf(firstFromTo); const firstFromToPosition = Math.min(...fromToWindows.map((win) => win.position)); - // A load-time `gsap.set(...)` runs before the paused timeline's playhead - // ever moves, so it establishes the resting state just as reliably as an - // in-timeline `tl.set(..., 0)` — and gsap_timeline_set_initial_hide - // already treats it that way (it exempts `win.global` outright). - // Accepting it here too stops the two rules from each demanding the - // shape the other flags. - const hasBaseline = gsapWindows.slice(0, firstFromToIndex).some((candidate) => { - if (candidate.method !== "set" || candidate.targetSelector !== selector) return false; - // The acorn parser flags `global` on any bare `gsap.set(...)` whatever - // its AST nesting, so one deferred behind a callback/handler is - // indistinguishable here; only the load-time scan separates them. - // Known gap: the scan reports selectors, not occurrences, so a second, - // genuinely load-time `gsap.set` for the same selector ANYWHERE in the - // composition (even after this candidate, even in another script) would - // also satisfy this check — accepted as narrow (requires duplicate - // `gsap.set` calls to one selector) rather than tracked per-occurrence. - if (candidate.global) return loadTimeSetSelectors.has(selector); - return candidate.position <= firstFromToPosition; - }); - if (hasBaseline) continue; + const hasTimelineBaseline = gsapWindows + .slice(0, firstFromToIndex) + .some( + (candidate) => + candidate.method === "set" && + !candidate.global && + candidate.targetSelector === selector && + candidate.position <= firstFromToPosition, + ); + if (hasTimelineBaseline) continue; findings.push({ code: "gsap_repeated_fromto_without_baseline", @@ -1178,11 +1148,10 @@ export const gsapRules: LintRule[] = [ `(immediateRender), not at tween position.`, selector, fixHint: - `Add \`immediateRender: false\` to the destination vars of each future fromTo, or establish the ` + - `resting state with a load-time \`gsap.set("${selector}", { ... })\` before the first fromTo. ` + - `Prefer that over an in-timeline \`tl.set("${selector}", { ... }, 0)\`, which ` + - `gsap_timeline_set_initial_hide flags when those values hide the element. Pre-first-tween seeks ` + - `must not inherit whichever fromTo call happened to author last.`, + `Add \`immediateRender: false\` to the destination vars of every fromTo after the first, so ` + + `pre-first-tween seeks keep the first fromTo's "from" values instead of the last-authored ones. ` + + `A hiding \`tl.set(..., 0)\` baseline trips gsap_timeline_set_initial_hide, and a \`gsap.set\` ` + + `authored before the fromTo calls is overwritten by them.`, snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n")), }); } From dec314963cbffa72d57d3eb9ff4771e340fb506e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 2 Oct 2026 03:41:08 -0700 Subject: [PATCH 3/3] fix(lint): name the earliest-positioned fromTo in the baseline hint --- packages/lint/src/rules/gsap.test.ts | 23 ++++++++++++++++++++++- packages/lint/src/rules/gsap.ts | 4 ++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index c47b65d2684..0f8dc494997 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -3056,10 +3056,31 @@ describe("SVG draw-on rules", () => { expect(finding?.severity).toBe("warning"); expect(finding?.selector).toBe("#ring"); - expect(finding?.fixHint).toContain("immediateRender: false"); + expect(finding?.fixHint).toContain("every fromTo except the earliest-positioned one (at 5s)"); expect(finding?.fixHint).toContain("gsap_timeline_set_initial_hide"); }); + it("gsap_repeated_fromto_without_baseline: accepts the hinted shape when the later tween is authored first", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", + ); + + expect(finding).toBeUndefined(); + }); + it("gsap_repeated_fromto_without_baseline: accepts explicit immediateRender false", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index ee26e07db56..721d2825782 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1148,8 +1148,8 @@ export const gsapRules: LintRule[] = [ `(immediateRender), not at tween position.`, selector, fixHint: - `Add \`immediateRender: false\` to the destination vars of every fromTo after the first, so ` + - `pre-first-tween seeks keep the first fromTo's "from" values instead of the last-authored ones. ` + + `Add \`immediateRender: false\` to every fromTo except the earliest-positioned one ` + + `(at ${firstFromToPosition}s), so pre-first-tween seeks keep its "from" values. ` + `A hiding \`tl.set(..., 0)\` baseline trips gsap_timeline_set_initial_hide, and a \`gsap.set\` ` + `authored before the fromTo calls is overwritten by them.`, snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n")),