From 2c14438bb038fd1b0dfd853c2004f89a8e6c6d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 8 Sep 2026 14:09:54 -0400 Subject: [PATCH] fix(lint): flag hidden-style opacity guards (#3694) --- packages/lint/src/rules/core.test.ts | 72 ++++++++++++++++++++++++ packages/lint/src/rules/core.ts | 82 +++++++++++++++++++++++++--- 2 files changed, 147 insertions(+), 7 deletions(-) diff --git a/packages/lint/src/rules/core.test.ts b/packages/lint/src/rules/core.test.ts index 12502b48e1..1efc3865c9 100644 --- a/packages/lint/src/rules/core.test.ts +++ b/packages/lint/src/rules/core.test.ts @@ -870,6 +870,78 @@ describe("core rules", () => { }); }); + describe("runtime_hidden_style_opacity", () => { + const comp = (css: string, extraMarkup = "") => ` + +
+ + ${extraMarkup} +
+ +`; + + it("errors when a broad hidden-style selector forces replacement-frame opacity to zero", async () => { + const result = await lintHyperframeHtml( + comp(`[style*="visibility: hidden"] { opacity: 0 !important; }`), + ); + const finding = result.findings.find((item) => item.code === "runtime_hidden_style_opacity"); + + 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"); + }); + + it("errors when composition scoping still leaves the hidden-style selector on video", async () => { + const result = await lintHyperframeHtml( + comp(`#root > video[style*="visibility: hidden"] { opacity: 0; }`), + ); + + expect( + result.findings.find((item) => item.code === "runtime_hidden_style_opacity")?.selector, + ).toBe(`#root > video[style*="visibility: hidden"]`); + }); + + it("errors when the root stylesheet can affect video mounted from a sub-composition", async () => { + const result = await lintHyperframeHtml(` + +
+
+
+ +`); + + expect( + result.findings.find((item) => item.code === "runtime_hidden_style_opacity")?.severity, + ).toBe("error"); + }); + + it("allows hidden-style opacity guards scoped to sub-composition hosts", async () => { + const result = await lintHyperframeHtml( + comp( + `[data-composition-src][style*="visibility: hidden"], + [data-composition-file][style*="visibility: hidden"] { opacity: 0 !important; }`, + `
+
`, + ), + ); + + expect( + result.findings.find((item) => item.code === "runtime_hidden_style_opacity"), + ).toBeUndefined(); + }); + + it("allows broad hidden-style selectors that do not change opacity", async () => { + const result = await lintHyperframeHtml( + comp(`[style*="visibility: hidden"] { pointer-events: none; }`), + ); + + expect( + result.findings.find((item) => item.code === "runtime_hidden_style_opacity"), + ).toBeUndefined(); + }); + }); + describe("css_parse_error — malformed CSS is reported instead of silently swallowed", () => { it("reports a css_parse_error finding for unparseable CSS", async () => { const html = ` diff --git a/packages/lint/src/rules/core.ts b/packages/lint/src/rules/core.ts index 6f8569034b..42c69335d9 100644 --- a/packages/lint/src/rules/core.ts +++ b/packages/lint/src/rules/core.ts @@ -97,6 +97,53 @@ function resolvedRuleSelectors(rule: postcss.Rule): string[] { ); } +function selectorAliasesRuntimeHiddenStyle(selector: string): boolean { + let unsafe = false; + try { + selectorParser((root) => { + root.each((selectorNode) => { + const subject: selectorParser.Node[] = []; + selectorNode.each((node) => { + if (node.type === "combinator") subject.length = 0; + else subject.push(node); + }); + + const hostScoped = subject.some( + (node) => + node.type === "attribute" && + ["data-composition-src", "data-composition-file"].includes( + node.attribute.toLowerCase(), + ), + ); + if (hostScoped) return; + + if ( + subject.some((node) => { + if (node.type !== "attribute" || node.attribute.toLowerCase() !== "style") return false; + if (node.operator !== "*=" || !node.value) return false; + const needle = node.insensitive ? node.value.toLowerCase() : node.value; + if (!needle.includes("visibility") && !needle.includes("hidden")) return false; + return "visibility: hidden !important;".includes(needle); + }) + ) { + unsafe = true; + } + }); + }).processSync(selector); + } catch { + return false; + } + return unsafe; +} + +function ruleForcesOpacityZero(rule: postcss.Rule): boolean { + let forcesOpacityZero = false; + rule.walkDecls(/^opacity$/i, (declaration) => { + if (Number(declaration.value.trim()) === 0) forcesOpacityZero = true; + }); + return forcesOpacityZero; +} + function isStudioTimelineElement(tag: { raw: string; name: string }): boolean { if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) { return false; @@ -333,10 +380,11 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ return findings; }, - // repeated_id_descendant_selector + // CSS selector safety ({ styles }) => { const findings: HyperframeLintFinding[] = []; - const reported = new Set(); + const reportedRepeatedIds = new Set(); + const reportedHiddenStyleSelectors = new Set(); for (const style of styles) { let root: postcss.Root; try { @@ -350,16 +398,36 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ continue; } root.walkRules((rule) => { + const forcesOpacityZero = ruleForcesOpacityZero(rule); for (const selector of resolvedRuleSelectors(rule)) { const repeatedId = repeatedDescendantId(selector); - if (!repeatedId || reported.has(repeatedId)) continue; - reported.add(repeatedId); + if (repeatedId && !reportedRepeatedIds.has(repeatedId)) { + reportedRepeatedIds.add(repeatedId); + findings.push({ + code: "repeated_id_descendant_selector", + severity: "error", + message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`, + selector, + fixHint: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`, + }); + } + + if ( + !forcesOpacityZero || + reportedHiddenStyleSelectors.has(selector) || + !selectorAliasesRuntimeHiddenStyle(selector) + ) { + continue; + } + reportedHiddenStyleSelectors.add(selector); findings.push({ - code: "repeated_id_descendant_selector", + code: "runtime_hidden_style_opacity", severity: "error", - message: `Selector "${selector}" requires #${repeatedId} to be nested inside another #${repeatedId}. IDs must be unique, so this selector cannot match a valid composition.`, + 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: `Remove the duplicate ancestor: change \`#${repeatedId} #${repeatedId}\` to \`#${repeatedId}\`.`, + 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.', + snippet: truncateSnippet(rule.toString()), }); } });