diff --git a/packages/lint/src/rules/gsap.test.ts b/packages/lint/src/rules/gsap.test.ts index 1ef53fa7b8..82d17fddcb 100644 --- a/packages/lint/src/rules/gsap.test.ts +++ b/packages/lint/src/rules/gsap.test.ts @@ -3139,3 +3139,117 @@ describe("SVG draw-on rules", () => { expect(finding).toBeUndefined(); }); }); + +describe("gsap_fromto_flash_before_start", () => { + const comp = (script: string, body = "", style = "") => ` + +
${body}
+ ${style ? `` : ""} + + +`; + + it("errors when a late fromTo jumps from a non-identity from-var on a visible element", async () => { + const result = await lintHyperframeHtml( + comp( + `tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, ease: "steps(12)" }, 2);`, + `
code
`, + ), + ); + const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"); + expect(finding).toBeDefined(); + expect(finding!.severity).toBe("error"); + expect(finding!.selector).toBe("#line"); + }); + + it("errors when a late fromTo fades in an element that was never hidden", async () => { + const result = await lintHyperframeHtml( + comp( + `tl.fromTo("#hero", { opacity: 0 }, { opacity: 1, duration: 0.5 }, 1.5);`, + `
Hi
`, + ), + ); + const finding = result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"); + expect(finding).toBeDefined(); + }); + + it("exempts a target hidden by authored CSS opacity:0", async () => { + const result = await lintHyperframeHtml( + comp( + `tl.fromTo("#line", { x: "-105%" }, { x: "0%", opacity: 1, duration: 0.8 }, 2);`, + `
code
`, + `#line { opacity: 0; }`, + ), + ); + expect( + result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"), + ).toBeUndefined(); + }); + + it("exempts a target pre-hidden by a timeline set at the timeline start", async () => { + const result = await lintHyperframeHtml( + comp( + `tl.set("#line", { opacity: 0 }, 0); + tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`, + `
code
`, + ), + ); + expect( + result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"), + ).toBeUndefined(); + }); + + it("exempts a target pre-hidden by a standalone gsap.set", async () => { + const result = await lintHyperframeHtml( + comp( + `gsap.set("#line", { opacity: 0 }); + tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 2);`, + `
code
`, + ), + ); + expect( + result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"), + ).toBeUndefined(); + }); + + it("exempts identity from-vars (no jump)", async () => { + const result = await lintHyperframeHtml( + comp( + `tl.fromTo("#drift", { x: 0 }, { x: 100, duration: 3 }, 5);`, + `
Hi
`, + ), + ); + expect( + result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"), + ).toBeUndefined(); + }); + + it("exempts immediateRender: true (from-vars hold from load)", async () => { + const result = await lintHyperframeHtml( + comp( + `tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8, immediateRender: true }, 2);`, + `
code
`, + ), + ); + expect( + result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"), + ).toBeUndefined(); + }); + + it("exempts tweens at the timeline start (zero flash window)", async () => { + const result = await lintHyperframeHtml( + comp( + `tl.fromTo("#line", { x: "-105%" }, { x: "0%", duration: 0.8 }, 0);`, + `
code
`, + ), + ); + expect( + result.findings.find((f) => f.code === "gsap_fromto_flash_before_start"), + ).toBeUndefined(); + }); +}); diff --git a/packages/lint/src/rules/gsap.ts b/packages/lint/src/rules/gsap.ts index 92290d9cf8..06e7f07775 100644 --- a/packages/lint/src/rules/gsap.ts +++ b/packages/lint/src/rules/gsap.ts @@ -1818,6 +1818,94 @@ export const gsapRules: LintRule[] = [ return findings; }, + // gsap_fromto_flash_before_start — a fromTo() positioned after the timeline's start + // renders with GSAP's default immediateRender: false. The target therefore sits in + // its AUTHORED state from t=0, then jumps to the tween's from-vars when it begins: + // a visible flash/jump on every playback and every cold seek. (Real-world casualty: + // a "typewriter" whose lines were fully readable before each line "typed".) + // Exemptions: the authored state is already hidden (CSS opacity:0, standalone + // gsap.set, or a timeline set-to-hidden at the timeline start) — nothing to flash; + // the from-vars are the transform/opacity identity — no jump; immediateRender: true — + // GSAP applies the from-vars at build time and holds them; and tweens starting at + // the timeline start, whose flash window is zero. + async ({ scripts, styles, tags }) => { + const findings: HyperframeLintFinding[] = []; + const cssHiddenSelectors = collectCssOpacityZeroSelectors(styles, tags); + const TRANSFORM_IDENTITY: Record = { + x: 0, + y: 0, + rotation: 0, + rotationX: 0, + rotationY: 0, + skewX: 0, + skewY: 0, + scale: 1, + scaleX: 1, + scaleY: 1, + }; + for (const script of scripts) { + if (!/gsap\.timeline/.test(script.content)) continue; + const windows = await cachedExtractGsapWindows(script.content); + const preHidden = new Set([ + ...cssHiddenSelectors, + ...extractStandaloneHiddenSelectors(script.content), + ]); + // A timeline set-to-hidden at the timeline start pre-hides the target for every + // seek; sets at later positions leave [0, position) visible, so they do not. + const initialHiddenSets = new Set( + windows + .filter( + (w) => + w.method === "set" && + w.position <= SCENE_BOUNDARY_EPSILON_SECONDS && + isHiddenGsapState(w.propertyValues), + ) + .map((w) => w.targetSelector), + ); + for (const win of windows) { + if (win.method !== "fromTo") continue; + if (win.immediateRender) continue; + if (win.position <= SCENE_BOUNDARY_EPSILON_SECONDS) continue; + if (targetHasNoStableIdentity(win.targetSelector, win.targetIdentity)) continue; + const sel = win.targetSelector; + const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`; + if (preHidden.has(cssKey) || initialHiddenSets.has(sel)) continue; + const from = win.fromPropertyValues; + if (!from) continue; + const jumps = Object.entries(from).some(([prop, value]) => { + const identity = TRANSFORM_IDENTITY[prop]; + if (identity !== undefined) { + const n = numberValue(value); + return n === null ? true : n !== identity; + } + if (prop === "opacity" || prop === "autoAlpha") { + const n = numberValue(value); + return n === null ? true : n !== 1; + } + return prop === "visibility" || prop === "display"; + }); + if (!jumps) continue; + findings.push({ + code: "gsap_fromto_flash_before_start", + severity: "error", + message: + `"${sel}" is visible from t=0 in its authored state, then jumps to this ` + + `tween's from-vars at ${win.position.toFixed(2)}s (timeline fromTo defaults ` + + "to immediateRender: false) — a visible flash on every playback and cold seek.", + selector: sel, + fixHint: + `Hide the authored state until the tween starts (CSS \`opacity: 0\` on "${sel}" ` + + 'with `opacity: 1` in the destination vars, or `tl.set("' + + sel + + '", { opacity: 0 }, 0)`), or pass `immediateRender: true` so the ' + + "from-vars hold from load.", + snippet: truncateSnippet(win.raw), + }); + } + } + return findings; + }, + // gsap_non_transform_motion — animating layout props (left/top/right/bottom/margin*) // or using roundProps snaps motion to integer device pixels. On the seek-by-frame // capture engine this looks smooth at high per-frame deltas (fast tweens) but visibly