feat(lint): flag CSS rules that select on the inline style attribute (#6261)

* feat(lint): flag CSS rules that select on the inline style attribute

* docs(lint): keep the new and corrected rule entries to short lines
This commit is contained in:
Miguel Ángel
2026-10-25 23:06:38 +00:00
committed by GitHub
parent 6f4d46ad34
commit e003815c30
3 changed files with 92 additions and 3 deletions
+11 -1
View File
@@ -258,6 +258,15 @@ Source: `packages/lint/src/rules/core.ts`.
**Fix:** Rename the id to start with a letter (recommended), or build selectors with `CSS.escape(id)` at runtime.
### `inline_style_attribute_selector`
**Error.** A selector reads the inline `style` attribute (`[style]`, `[style*=...]` and the other forms).
GSAP writes inline styles every frame, and each write then restyles far more than the animated element.
The preview slows to a crawl.
**Fix:** Let the runtime hide hidden clips; it already hides their contents.
For a state of your own, toggle a class or data attribute from your script and select on that.
### `invalid_inline_script_syntax`
**Error.** An inline `<script>` is closed with malformed syntax.
@@ -304,7 +313,8 @@ Source: `packages/lint/src/rules/core.ts`.
**Error.** A selector observes the runtime-owned hidden style (used to hide native video during frame copying) and forces `opacity` to zero, which breaks the video replacement pipeline.
**Fix:** Restrict the guard to sub-composition hosts (for example `[data-composition-src][style*="visibility: hidden"]`) and do not derive element or media opacity from the runtime's hidden style.
**Fix:** Remove the rule; the runtime already hides a hidden clip and its contents.
To fade or hide something yourself, animate it on the timeline, or toggle a class or data attribute and select on that.
### `scoped_css_missing_wrapper`
+47 -1
View File
@@ -1419,7 +1419,8 @@ describe("core rules", () => {
expect(finding?.severity).toBe("error");
expect(finding?.selector).toBe(`[style*="visibility: hidden"]`);
expect(finding?.message).toContain("replacement frame");
expect(finding?.fixHint).toContain("data-composition-src");
expect(finding?.fixHint).not.toContain("[style");
expect(finding?.fixHint).toContain("class or data attribute");
});
it("errors when composition scoping still leaves the hidden-style selector on video", async () => {
@@ -1472,6 +1473,51 @@ describe("core rules", () => {
});
});
describe("inline_style_attribute_selector", () => {
const findingsFor = async (css: string) =>
(
await lintHyperframeHtml(`
<html><head><style>${css}</style></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080"></div>
<script>window.__timelines = { main: gsap.timeline({ paused: true }) };</script>
</body></html>`)
).findings.filter((item) => item.code === "inline_style_attribute_selector");
it.each([
`[style] { color: red; }`,
`.scene[style*="visibility: hidden"] * { visibility: hidden !important; }`,
`div[style^="opacity"] { color: red; }`,
`div[style$="none;"] { color: red; }`,
`div[style~="x"] { color: red; }`,
`div[style|="x"] { color: red; }`,
`[STYLE*="hidden" i] { color: red; }`,
`.card:not([style*="opacity: 0"]) { color: red; }`,
])("errors on a rule that selects on the inline style attribute: %s", async (css) => {
const [finding] = await findingsFor(css);
expect(finding?.severity).toBe("error");
expect(finding?.message).toContain("every frame");
expect(finding?.fixHint).toContain("class or data attribute");
expect(finding?.fixHint).not.toContain("[style");
});
it.each([
`.scene.is-hidden * { visibility: hidden; }`,
`[data-state="hidden"] { opacity: 0; }`,
`[data-style="bold"] { font-weight: 700; }`,
`[styles] { color: red; }`,
`[data-hf-style] { color: red; }`,
])("allows a selector that does not read the inline style: %s", async (css) => {
expect(await findingsFor(css)).toEqual([]);
});
it("reports a selector once and each selector of a list on its own", async () => {
const findings = await findingsFor(
`[style*="a"], .x[style] { color: red; } [style*="a"] { color: blue; }`,
);
expect(findings.map((item) => item.selector)).toEqual([`[style*="a"]`, `.x[style]`]);
});
});
describe("id_override_reduced_specificity", () => {
const comp = (css: string) => `
<html><head><style>${css}</style></head><body>
+34 -1
View File
@@ -196,6 +196,20 @@ function selectorAliasesRuntimeHiddenStyle(selector: string): boolean {
return unsafe;
}
function selectsOnInlineStyle(selector: string): boolean {
let found = false;
try {
selectorParser((root) => {
root.walkAttributes((node) => {
if (node.attribute.toLowerCase() === "style") found = true;
});
}).processSync(selector);
} catch {
return false;
}
return found;
}
const POSITION_PROPERTIES = new Set(["left", "top", "right", "bottom", "inset"]);
function weakIdOf(node: selectorParser.Node): string | null {
@@ -713,6 +727,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
const findings: HyperframeLintFinding[] = [];
const reportedRepeatedIds = new Set<string>();
const reportedHiddenStyleSelectors = new Set<string>();
const reportedInlineStyleSelectors = new Set<string>();
const reportedReducedIdSelectors = new Set<string>();
for (const style of styles) {
let root: postcss.Root;
@@ -748,6 +763,23 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
});
}
if (!reportedInlineStyleSelectors.has(selector) && selectsOnInlineStyle(selector)) {
reportedInlineStyleSelectors.add(selector);
findings.push({
code: "inline_style_attribute_selector",
severity: "error",
message:
`Selector "${selector}" reads the inline \`style\` attribute. GSAP writes inline styles every ` +
"frame, and each write then restyles far more than the animated element, " +
"so the preview slows to a crawl.",
selector,
fixHint:
"Let the runtime hide hidden clips; it already hides their contents. For a state of your own, " +
"toggle a class or data attribute from your script and select on that.",
snippet: truncateSnippet(rule.toString()),
});
}
if (
!forcesOpacityZero ||
reportedHiddenStyleSelectors.has(selector) ||
@@ -762,7 +794,8 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
message: `Selector "${selector}" observes HyperFrames' runtime-owned hidden style and forces opacity to zero. The renderer hides each native video before copying its computed opacity to the visible replacement frame, so this rule makes both transparent.`,
selector,
fixHint:
'Restrict the guard to sub-composition hosts, for example `[data-composition-src][style*="visibility: hidden"]` and `[data-composition-file][style*="visibility: hidden"]`. Do not derive arbitrary element or media opacity from runtime-owned inline visibility.',
"Remove this rule; the runtime already hides a hidden clip and its contents. To fade or hide something " +
"yourself, animate it on the timeline, or toggle a class or data attribute and select on that.",
snippet: truncateSnippet(rule.toString()),
});
}