diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 97511a7cff..dee02f7919 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -2886,6 +2886,233 @@ 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("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", + ); + + 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 = ` + +
+
+
+ + +`; + 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..cc82192fc0 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), @@ -1108,6 +1111,53 @@ 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, + ); + 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) 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 \`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")), + }); + } + // gsap_exit_missing_hard_kill if (clipStartBoundaries.length > 0) { for (const win of gsapWindows) { @@ -2362,7 +2412,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 =