diff --git a/docs/reference/lint-rules.mdx b/docs/reference/lint-rules.mdx index f4d4e3fbe5..4055885250 100644 --- a/docs/reference/lint-rules.mdx +++ b/docs/reference/lint-rules.mdx @@ -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 ` +`) + ).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) => ` diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index f0c302fc3c..93af58dd84 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -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(); const reportedHiddenStyleSelectors = new Set(); + const reportedInlineStyleSelectors = new Set(); const reportedReducedIdSelectors = new Set(); 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()), }); }