From 0f677b35cf98670c8e2dc9787ec9d6b0f18c3e3a Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Sat, 12 Sep 2026 04:45:14 +0000 Subject: [PATCH] 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 687b36d2e6..b1d851919b 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -3259,7 +3259,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 = `
@@ -3278,6 +3282,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 e1abb94f6a..fa793d724e 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -245,7 +245,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 @@ -258,7 +258,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); @@ -274,16 +284,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[], @@ -1133,6 +1147,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 }; @@ -1216,16 +1236,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", @@ -1237,9 +1267,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")), }); }