From 813e96181a966bef28e4dd62a04b83b02f1edf11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 5 Sep 2026 12:23:12 +0000 Subject: [PATCH 1/2] fix(lint): flag repeated fromTo state leaks --- packages/lint/src/rules/gsap.test.ts | 167 +++++++++++++++++++++++++++ packages/lint/src/rules/gsap.ts | 79 +++++++++++-- 2 files changed, 238 insertions(+), 8 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 97511a7cff..e9c26ac8f3 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -2886,6 +2886,173 @@ describe("SVG draw-on rules", () => { expect(finding).toBeUndefined(); }); + it("gsap_repeated_fromto_without_baseline: warns for repeated future fromTo state", async () => { + 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"); + expect(finding?.selector).toBe("#ring"); + }); + + it("gsap_repeated_fromto_without_baseline: accepts explicit immediateRender false", 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.each([ + ["timeline", 'tl.set("#ring", { opacity: 0, scale: 1 }, 0);'], + ["standalone", 'gsap.set("#ring", { opacity: 0, scale: 1 });'], + ])( + "gsap_repeated_fromto_without_baseline: accepts an earlier %s set baseline", + async (_kind, baseline) => { + 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: does not treat a deferred callback set as baseline", async () => { + 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"); + }); + + it("gsap_repeated_fromto_without_baseline: requires a timeline baseline to be 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?.severity).toBe("warning"); + }); + + it("gsap_repeated_fromto_without_baseline: accepts one future fromTo writer", async () => { + const html = ` + +
+ + +`; + const result = await lintHyperframeHtml(html); + + expect( + result.findings.find( + (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", + ), + ).toBeUndefined(); + }); + + it("gsap_repeated_fromto_without_baseline: keeps different selectors independent", async () => { + const html = ` + +
+
+
+ + +`; + const result = await lintHyperframeHtml(html); + + expect( + result.findings.find( + (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", + ), + ).toBeUndefined(); + }); + // ── svg_measure_before_path_d ────────────────────────────────────────────── it("svg_measure_before_path_d: ERROR when no d assignment exists anywhere", async () => { diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 6cfa54b17d..efa7795126 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -55,7 +55,8 @@ type GsapWindow = { propertyValues: Record; fromPropertyValues?: Record; overwriteAuto: boolean; - immediateRender: boolean; + /** Explicit immediateRender option; undefined keeps the GSAP method default. */ + immediateRender?: boolean; method: string; /** True for an off-timeline `gsap.set(...)` (applied once at load). */ global?: boolean; @@ -150,6 +151,7 @@ async function extractGsapWindows(script: string): Promise { const cycleCount = infiniteRepeat ? 1 : repeat > 0 ? repeat + 1 : 1; const effectiveDuration = animation.method === "set" ? 0 : (animation.duration ?? 0) * cycleCount; + const immediateRender = unwrapRaw(animation.extras?.immediateRender); windows.push({ targetSelector: animation.targetSelector, targetIdentity: animation.targetIdentity, @@ -162,7 +164,8 @@ async function extractGsapWindows(script: string): Promise { propertyValues: animation.properties, fromPropertyValues: animation.fromProperties, overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto", - immediateRender: unwrapRaw(animation.extras?.immediateRender) === "true", + immediateRender: + immediateRender === "true" ? true : immediateRender === "false" ? false : undefined, method: animation.method, global: animation.global, raw: synthesizeWindowRaw(parsed.timelineVar, animation), @@ -203,8 +206,10 @@ function isHiddenGsapState(values: Record): boolean { ); } -function extractStandaloneHiddenSelectors(script: string): Set { - const selectors = new Set(); +type LoadTimeStandaloneGsapSet = { selector: string; propertiesSource: string }; + +function extractLoadTimeStandaloneGsapSets(script: string): LoadTimeStandaloneGsapSet[] { + const sets: LoadTimeStandaloneGsapSet[] = []; const source = stripJsComments(script); const functionRanges = collectFunctionBodyRanges(source); const aliases = new Map(); @@ -221,10 +226,16 @@ function extractStandaloneHiddenSelectors(script: string): Set { const target = (match[1] ?? "").trim(); const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target); if (!selector) continue; - const body = match[2] ?? ""; - if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) { + sets.push({ selector, propertiesSource: match[2] ?? "" }); + } + return sets; +} + +function extractStandaloneHiddenSelectors(script: string): Set { + const selectors = new Set(); + for (const { selector, propertiesSource } of extractLoadTimeStandaloneGsapSets(script)) { + if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(propertiesSource)) selectors.add(selector); - } } return selectors; } @@ -1108,6 +1119,58 @@ export const gsapRules: LintRule[] = [ } } + // gsap_repeated_fromto_without_baseline + const fromToWindowsBySelector = new Map(); + for (const win of gsapWindows) { + if (win.method !== "fromTo" || win.immediateRender === false) continue; + if (win.targetSelector === UNRESOLVED_TARGET) continue; + const windows = fromToWindowsBySelector.get(win.targetSelector) ?? []; + windows.push(win); + fromToWindowsBySelector.set(win.targetSelector, windows); + } + + const repeatedFromToGroups = [...fromToWindowsBySelector.values()].filter( + (windows) => windows.length >= 2, + ); + const standaloneSetSelectors = + repeatedFromToGroups.length > 0 + ? new Set(extractLoadTimeStandaloneGsapSets(script.content).map((set) => set.selector)) + : new Set(); + + for (const fromToWindows of repeatedFromToGroups) { + const firstFromTo = fromToWindows[0]; + if (!firstFromTo) continue; + 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 || standaloneSetSelectors.has(selector)) continue; + + findings.push({ + code: "gsap_repeated_fromto_without_baseline", + severity: "warning", + message: + `${fromToWindows.length} tl.fromTo() calls target "${selector}" with no stable baseline. ` + + `The last-authored fromTo "from" values become the element's resting state for any seek before ` + + `the first tween actually runs, because GSAP applies fromTo from-values at authoring time ` + + `(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 \`gsap.set("${selector}", { ... })\`. Pre-first-tween seeks must not ` + + `inherit whichever fromTo call happened to author last.`, + snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n")), + }); + } + // gsap_exit_missing_hard_kill if (clipStartBoundaries.length > 0) { for (const win of gsapWindows) { @@ -2362,7 +2425,7 @@ export const gsapRules: LintRule[] = [ const initialHolds = firstTweenIndex < 0 ? windows : windows.slice(0, firstTweenIndex); for (const win of initialHolds) { if (!isInstantHold(win) || win.position !== 0) continue; - if (win.global || win.immediateRender) continue; + if (win.global || win.immediateRender === true) continue; if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue; const targetTokens = [...targetedSelectorTokens(win.targetSelector)]; const hiddenByToken = From 4ad384b41d7804b00a6e8659640a2d30d60872ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 5 Sep 2026 15:53:03 +0000 Subject: [PATCH 2/2] fix(lint): require a timeline baseline for repeated fromTo --- packages/lint/src/rules/gsap.test.ts | 90 +++++++++++++++++++++++----- packages/lint/src/rules/gsap.ts | 27 +++------ 2 files changed, 82 insertions(+), 35 deletions(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index e9c26ac8f3..dee02f7919 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -2932,33 +2932,93 @@ describe("SVG draw-on rules", () => { expect(finding).toBeUndefined(); }); - it.each([ - ["timeline", 'tl.set("#ring", { opacity: 0, scale: 1 }, 0);'], - ["standalone", 'gsap.set("#ring", { opacity: 0, scale: 1 });'], - ])( - "gsap_repeated_fromto_without_baseline: accepts an earlier %s set baseline", - async (_kind, baseline) => { - const html = ` + it("gsap_repeated_fromto_without_baseline: accepts an earlier timeline set baseline", async () => { + const html = `
`; - const result = await lintHyperframeHtml(html); - const finding = result.findings.find( - (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", - ); + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (candidate) => candidate.code === "gsap_repeated_fromto_without_baseline", + ); - expect(finding).toBeUndefined(); - }, - ); + expect(finding).toBeUndefined(); + }); + + it("gsap_repeated_fromto_without_baseline: rejects an earlier standalone set", async () => { + 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"); + }); + + it("gsap_repeated_fromto_without_baseline: rejects a later incomplete standalone set", async () => { + 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"); + }); + + it("gsap_repeated_fromto_without_baseline: does not guess that a later standalone set is a timeline baseline", async () => { + 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"); + }); it("gsap_repeated_fromto_without_baseline: does not treat a deferred callback set as baseline", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index efa7795126..cc82192fc0 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -206,10 +206,8 @@ function isHiddenGsapState(values: Record): boolean { ); } -type LoadTimeStandaloneGsapSet = { selector: string; propertiesSource: string }; - -function extractLoadTimeStandaloneGsapSets(script: string): LoadTimeStandaloneGsapSet[] { - const sets: LoadTimeStandaloneGsapSet[] = []; +function extractStandaloneHiddenSelectors(script: string): Set { + const selectors = new Set(); const source = stripJsComments(script); const functionRanges = collectFunctionBodyRanges(source); const aliases = new Map(); @@ -226,16 +224,10 @@ function extractLoadTimeStandaloneGsapSets(script: string): LoadTimeStandaloneGs const target = (match[1] ?? "").trim(); const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target); if (!selector) continue; - sets.push({ selector, propertiesSource: match[2] ?? "" }); - } - return sets; -} - -function extractStandaloneHiddenSelectors(script: string): Set { - const selectors = new Set(); - for (const { selector, propertiesSource } of extractLoadTimeStandaloneGsapSets(script)) { - if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(propertiesSource)) + const body = match[2] ?? ""; + if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) { selectors.add(selector); + } } return selectors; } @@ -1132,11 +1124,6 @@ export const gsapRules: LintRule[] = [ const repeatedFromToGroups = [...fromToWindowsBySelector.values()].filter( (windows) => windows.length >= 2, ); - const standaloneSetSelectors = - repeatedFromToGroups.length > 0 - ? new Set(extractLoadTimeStandaloneGsapSets(script.content).map((set) => set.selector)) - : new Set(); - for (const fromToWindows of repeatedFromToGroups) { const firstFromTo = fromToWindows[0]; if (!firstFromTo) continue; @@ -1152,7 +1139,7 @@ export const gsapRules: LintRule[] = [ candidate.targetSelector === selector && candidate.position <= firstFromToPosition, ); - if (hasTimelineBaseline || standaloneSetSelectors.has(selector)) continue; + if (hasTimelineBaseline) continue; findings.push({ code: "gsap_repeated_fromto_without_baseline", @@ -1165,7 +1152,7 @@ export const gsapRules: LintRule[] = [ selector, fixHint: `Add \`immediateRender: false\` to the destination vars of each future fromTo, or set a safe ` + - `resting state with an earlier \`gsap.set("${selector}", { ... })\`. Pre-first-tween seeks must not ` + + `resting state with an earlier \`tl.set("${selector}", { ... }, 0)\`. Pre-first-tween seeks must not ` + `inherit whichever fromTo call happened to author last.`, snippet: truncateSnippet(fromToWindows.map((win) => win.raw).join("\n")), });