From a6e86ad13bcb695146da32409aad017501bf379d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 9 Jul 2026 00:44:52 +0000 Subject: [PATCH] fix(lint): recognize gsap.fromTo initial state in overlay-visibility check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsap_fullscreen_overlay_starts_visible only looked at a tween's destination values (toVars) when deciding whether an element starts hidden at t=0. For a fromTo() call this destination is the wrong state to check — a fromTo(el, {opacity:0}, {opacity:1}, 0) fade-in genuinely starts hidden, but its propertyValues only captured opacity:1, so the rule treated it as starting visible. This misfired specifically when the element was later hidden again (e.g. an outro fade), since a guard that otherwise suppressed bare fromTo entrances no longer applied once a later hide was detected. Thread the parser's existing fromProperties (fromTo's "from" vars, already parsed but previously unused by this rule) onto GsapWindow as fromPropertyValues, and prefer it over propertyValues when checking a window's start state. --- packages/lint/src/rules/gsap.test.ts | 42 ++++++++++++++++++++++++++++ packages/lint/src/rules/gsap.ts | 18 +++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 2715ff5a9c..ad991aac9c 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -214,6 +214,48 @@ describe("GSAP rules", () => { expect(finding).toBeUndefined(); }); + it("does not error when a full-frame overlay uses a GSAP fromTo entrance at frame zero", async () => { + const html = ` + +
+

Scene 1

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeUndefined(); + }); + + it("does not error when a full-frame overlay's GSAP fromTo entrance at frame zero is later hidden again", async () => { + const html = ` + +
+

Scene 1

+

Scene 2

+ + +`; + const result = await lintHyperframeHtml(html); + const finding = result.findings.find( + (f) => f.code === "gsap_fullscreen_overlay_starts_visible", + ); + expect(finding).toBeUndefined(); + }); + it("reports one full-frame transition flash finding when multiple scripts touch it", async () => { const html = ` diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index b54b7c8732..f5600892a9 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -42,6 +42,10 @@ type GsapWindow = { end: number; properties: string[]; propertyValues: Record; + // fromTo()'s first ("from") vars object — the true state at the tween's start, + // as opposed to propertyValues which is always the destination (toVars). Only + // set for method === "fromTo"; other methods have no separate from-state. + fromPropertyValues?: Record; overwriteAuto: boolean; method: string; raw: string; @@ -141,6 +145,7 @@ async function extractGsapWindows(script: string): Promise { end: animation.position + effectiveDuration, properties: Object.keys(animation.properties), propertyValues: animation.properties, + fromPropertyValues: animation.fromProperties, overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto", method: animation.method, raw: synthesizeWindowRaw(parsed.timelineVar, animation), @@ -209,6 +214,16 @@ function isVisibleGsapState(values: Record): boolean { return false; } +// The property values a window's element actually holds at the window's own +// start (position). For fromTo() this is the authored fromVars, not the +// destination toVars captured in propertyValues — a fade-in via +// `fromTo(el, {opacity:0}, {opacity:1}, 0)` genuinely starts hidden even +// though propertyValues (the toVars) says opacity: 1. +function windowStartStateValues(win: GsapWindow): Record { + if (win.method === "fromTo") return win.fromPropertyValues ?? win.propertyValues; + return win.propertyValues; +} + function makesOverlayVisible(win: GsapWindow): boolean { if (win.method === "from" && isHiddenGsapState(win.propertyValues)) return true; return isVisibleGsapState(win.propertyValues); @@ -680,7 +695,8 @@ export const gsapRules: LintRule[] = [ .sort((a, b) => a.position - b.position); const startsHiddenAtZero = visibilityWindows.some( (win) => - win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && isHiddenGsapState(win.propertyValues), + win.position <= SCENE_BOUNDARY_EPSILON_SECONDS && + isHiddenGsapState(windowStartStateValues(win)), ); if (startsHiddenAtZero) continue; const firstVisible = visibilityWindows.find((win) => makesOverlayVisible(win));