Skip to content

Commit 55ee559

Browse files
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
1 parent 9b17a5a commit 55ee559

2 files changed

Lines changed: 99 additions & 6 deletions

File tree

packages/lint/src/rules/gsap.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1393,6 +1393,49 @@ describe("GSAP rules", () => {
13931393
expect(finding).toBeUndefined();
13941394
});
13951395

1396+
it("errors when CSS-hidden content has a fromTo reveal without a destination opacity", async () => {
1397+
const html = `
1398+
<html><body>
1399+
<div data-composition-id="c1" data-width="1920" data-height="1080">
1400+
<div id="card" style="opacity: 0">Visible after entrance</div>
1401+
</div>
1402+
<script>
1403+
window.__timelines = window.__timelines || {};
1404+
const tl = gsap.timeline({ paused: true });
1405+
tl.fromTo("#card", { opacity: 1, x: -60 }, { x: 0, duration: 0.5, immediateRender: false }, 1);
1406+
window.__timelines["c1"] = tl;
1407+
</script>
1408+
</body></html>`;
1409+
const result = await lintHyperframeHtml(html);
1410+
const finding = result.findings.find(
1411+
(f) => f.code === "gsap_cold_seek_hidden_fromto_missing_reveal",
1412+
);
1413+
expect(finding).toBeDefined();
1414+
expect(finding?.severity).toBe("error");
1415+
expect(finding?.selector).toBe("#card");
1416+
});
1417+
1418+
it("errors when standalone gsap.set hides a fromTo target with no destination opacity", async () => {
1419+
const html = `
1420+
<html><body>
1421+
<div data-composition-id="c1" data-width="1920" data-height="1080">
1422+
<div id="card">Visible after entrance</div>
1423+
</div>
1424+
<script>
1425+
window.__timelines = window.__timelines || {};
1426+
gsap.set("#card", { opacity: 0 });
1427+
const tl = gsap.timeline({ paused: true });
1428+
tl.fromTo("#card", { opacity: 1, x: -60 }, { x: 0, duration: 0.5 }, 1);
1429+
window.__timelines["c1"] = tl;
1430+
</script>
1431+
</body></html>`;
1432+
const result = await lintHyperframeHtml(html);
1433+
const finding = result.findings.find(
1434+
(f) => f.code === "gsap_cold_seek_hidden_fromto_missing_reveal",
1435+
);
1436+
expect(finding).toBeDefined();
1437+
});
1438+
13961439
it("does NOT error when gsap.to() uses opacity:0 (exit animation)", async () => {
13971440
const html = `
13981441
<html><body>

packages/lint/src/rules/gsap.ts

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type GsapWindow = {
4545
end: number;
4646
properties: string[];
4747
propertyValues: Record<string, string | number>;
48+
fromPropertyValues?: Record<string, string | number>;
4849
overwriteAuto: boolean;
4950
method: string;
5051
raw: string;
@@ -155,6 +156,7 @@ async function extractGsapWindows(script: string): Promise<GsapWindow[]> {
155156
end: animation.position + effectiveDuration,
156157
properties: Object.keys(animation.properties),
157158
propertyValues: animation.properties,
159+
fromPropertyValues: animation.fromProperties,
158160
overwriteAuto: unwrapRaw(animation.extras?.overwrite) === "auto",
159161
method: animation.method,
160162
raw: synthesizeWindowRaw(parsed.timelineVar, animation),
@@ -195,6 +197,29 @@ function isHiddenGsapState(values: Record<string, string | number>): boolean {
195197
);
196198
}
197199

200+
function extractStandaloneHiddenSelectors(script: string): Set<string> {
201+
const selectors = new Set<string>();
202+
const source = stripJsComments(script);
203+
const aliases = new Map<string, string>();
204+
for (const match of source.matchAll(
205+
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(["'`])([^"'`]+)\2\s*;/g,
206+
)) {
207+
aliases.set(match[1] ?? "", match[3] ?? "");
208+
}
209+
const pattern = /gsap\.set\s*\(\s*([^,]+?)\s*,\s*\{([\s\S]*?)\}\s*\)/g;
210+
let match: RegExpExecArray | null;
211+
while ((match = pattern.exec(source)) !== null) {
212+
const target = (match[1] ?? "").trim();
213+
const selector = /^(["'`])([^"'`]+)\1$/.exec(target)?.[2] ?? aliases.get(target);
214+
if (!selector) continue;
215+
const body = match[2] ?? "";
216+
if (/(?:opacity|autoAlpha)\s*:\s*0(?:\.0+)?\s*(?:,|$)/.test(body)) {
217+
selectors.add(selector);
218+
}
219+
}
220+
return selectors;
221+
}
222+
198223
function oneValue(
199224
values: Record<string, string | number>,
200225
keys: string[],
@@ -1138,7 +1163,10 @@ export const gsapRules: LintRule<LintContext>[] = [
11381163
return findings;
11391164
},
11401165

1141-
// gsap_from_opacity_noop — CSS opacity:0 + gsap.from({opacity:0}) = invisible forever
1166+
// CSS/GSAP-hidden reveal safety. A fromTo() whose from-vars make an element
1167+
// visible but whose destination omits opacity works during sequential seeks,
1168+
// yet cold render workers restore the authored hidden state and encode it
1169+
// permanently invisible.
11421170
// fallow-ignore-next-line complexity
11431171
async ({ styles, scripts, tags }) => {
11441172
const findings: HyperframeLintFinding[] = [];
@@ -1170,20 +1198,42 @@ export const gsapRules: LintRule<LintContext>[] = [
11701198
for (const cls of classes) cssOpacityZeroSelectors.add(`.${cls}`);
11711199
}
11721200

1173-
if (cssOpacityZeroSelectors.size === 0) return findings;
1174-
11751201
for (const script of scripts) {
11761202
if (!/gsap\.timeline/.test(script.content)) continue;
11771203
const windows = await cachedExtractGsapWindows(script.content);
1204+
const hiddenSelectors = new Set([
1205+
...cssOpacityZeroSelectors,
1206+
...extractStandaloneHiddenSelectors(script.content),
1207+
]);
11781208

11791209
for (const win of windows) {
1210+
const sel = win.targetSelector;
1211+
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
1212+
if (!hiddenSelectors.has(cssKey)) continue;
1213+
1214+
if (
1215+
win.method === "fromTo" &&
1216+
win.fromPropertyValues &&
1217+
isVisibleGsapState(win.fromPropertyValues) &&
1218+
!win.properties.some((property) => property === "opacity" || property === "autoAlpha")
1219+
) {
1220+
findings.push({
1221+
code: "gsap_cold_seek_hidden_fromto_missing_reveal",
1222+
severity: "error",
1223+
message:
1224+
`"${sel}" starts hidden, but its gsap.fromTo() makes it visible only in the from-vars ` +
1225+
"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.",
1226+
selector: sel,
1227+
fixHint: `Add \`opacity: 1\` (or \`autoAlpha: 1\`) to the destination vars for "${sel}" so every seek path establishes the visible end state explicitly.`,
1228+
snippet: truncateSnippet(win.raw),
1229+
});
1230+
continue;
1231+
}
1232+
11801233
if (win.method !== "from") continue;
11811234
if (!win.properties.includes("opacity")) continue;
11821235
// Only a noop when the tween animates FROM 0 (same as the CSS value)
11831236
if (win.propertyValues["opacity"] !== 0) continue;
1184-
const sel = win.targetSelector;
1185-
const cssKey = sel.startsWith("#") || sel.startsWith(".") ? sel : `#${sel}`;
1186-
if (!cssOpacityZeroSelectors.has(cssKey)) continue;
11871237

11881238
findings.push({
11891239
code: "gsap_from_opacity_noop",

0 commit comments

Comments
 (0)