mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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`
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user