fix(lint): catch cold-seek opacity reveals (#2503)

* fix(lint): catch cold-seek opacity reveals

* fix(lint): resolve hidden selector aliases

* style(lint): format gsap rule
This commit is contained in:
Miguel Ángel
2026-07-16 12:03:25 -04:00
committed by GitHub
parent 9b17a5ad7e
commit 55ee559e40
2 changed files with 99 additions and 6 deletions
+43
View File
@@ -1393,6 +1393,49 @@ describe("GSAP rules", () => {
expect(finding).toBeUndefined();
});
it("errors when CSS-hidden content has a fromTo reveal without a destination opacity", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card" style="opacity: 0">Visible after entrance</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#card", { opacity: 1, x: -60 }, { x: 0, duration: 0.5, immediateRender: false }, 1);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "gsap_cold_seek_hidden_fromto_missing_reveal",
);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe("#card");
});
it("errors when standalone gsap.set hides a fromTo target with no destination opacity", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="card">Visible after entrance</div>
</div>
<script>
window.__timelines = window.__timelines || {};
gsap.set("#card", { opacity: 0 });
const tl = gsap.timeline({ paused: true });
tl.fromTo("#card", { opacity: 1, x: -60 }, { x: 0, duration: 0.5 }, 1);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "gsap_cold_seek_hidden_fromto_missing_reveal",
);
expect(finding).toBeDefined();
});
it("does NOT error when gsap.to() uses opacity:0 (exit animation)", async () => {
const html = `
<html><body>
+56 -6
View File
@@ -45,6 +45,7 @@ type GsapWindow = {
end: number;
properties: string[];
propertyValues: Record<string, string | number>;
fromPropertyValues?: Record<string, string | number>;
overwriteAuto: boolean;
method: string;
raw: string;
@@ -155,6 +156,7 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
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),
@@ -195,6 +197,29 @@ function isHiddenGsapState(values: Record<string, string | number>): boolean {
);
}
function extractStandaloneHiddenSelectors(script: string): Set<string> {
const selectors = new Set<string>();
const source = stripJsComments(script);
const aliases = new Map<string, string>();
for (const match of source.matchAll(
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])([^"'`]+)\2\s*;/g,
)) {
aliases.set(match[1] ?? "", match[3] ?? "");
}
const pattern = /gsap\.set\s*\(\s*([^,]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
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)) {
selectors.add(selector);
}
}
return selectors;
}
function oneValue(
values: Record<string, string | number>,
keys: string[],
@@ -1138,7 +1163,10 @@ export const gsapRules: LintRule<LintContext>[] = [
return findings;
},
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
// CSS/GSAP-hidden reveal safety. A fromTo() whose from-vars make an element
// visible but whose destination omits opacity works during sequential seeks,
// yet cold render workers restore the authored hidden state and encode it
// permanently invisible.
// fallow-ignore-next-line complexity
async ({ styles, scripts, tags }) => {
const findings: HyperframeLintFinding[] = [];
@@ -1170,20 +1198,42 @@ export const gsapRules: LintRule<LintContext>[] = [
for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`);
}
if (cssOpacityZeroSelectors.size === 0) return findings;
for (const script of scripts) {
if (!/gsap\.timeline/.test(script.content)) continue;
const windows = await cachedExtractGsapWindows(script.content);
const hiddenSelectors = new Set([
...cssOpacityZeroSelectors,
...extractStandaloneHiddenSelectors(script.content),
]);
for (const win of windows) {
const sel = win.targetSelector;
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
if (!hiddenSelectors.has(cssKey)) continue;
if (
win.method === "fromTo" &&
win.fromPropertyValues &&
isVisibleGsapState(win.fromPropertyValues) &&
!win.properties.some((property) => property === "opacity" || property === "autoAlpha")
) {
findings.push({
code: "gsap_cold_seek_hidden_fromto_missing_reveal",
severity: "error",
message:
`"${sel}" starts hidden, but its gsap.fromTo() makes it visible only in the from-vars ` +
"and omits opacity/autoAlpha from the destination. Cold render workers restore the hidden authored state, so the encoded element can stay invisible even when sequential snapshots look correct.",
selector: sel,
fixHint: `Add \`opacity: 1\` (or \`autoAlpha: 1\`) to the destination vars for "${sel}" so every seek path establishes the visible end state explicitly.`,
snippet: truncateSnippet(win.raw),
});
continue;
}
if (win.method !== "from") continue;
if (!win.properties.includes("opacity")) continue;
// Only a noop when the tween animates FROM 0 (same as the CSS value)
if (win.propertyValues["opacity"] !== 0) continue;
const sel = win.targetSelector;
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
if (!cssOpacityZeroSelectors.has(cssKey)) continue;
findings.push({
code: "gsap_from_opacity_noop",