fix(lint): allow scoped descendant zoom (#6254)

* fix(lint): allow scoped descendant zoom

* chore(lint): account for scoped zoom bundle size

* fix(lint): retain canvas zoom guard for runtime identity

* fix(lint): preserve runtime identity in selector functions

* fix(lint): retain identity across uncertain selector branches
This commit is contained in:
Akshar Patel
2026-10-25 01:53:04 +00:00
committed by GitHub
parent d44f67ac1d
commit e2129c3915
4 changed files with 477 additions and 34 deletions
+2
View File
@@ -112,6 +112,8 @@ Detected issues include:
- Missing `class="clip"` on timed visible elements
- Deprecated attribute names
- Missing composition dimensions (`data-width`, `data-height`)
- CSS `zoom` on the composition root or its ancestors (`root_zoom_rescales_a_fixed_canvas`);
zoom on descendants is allowed, including scoped selectors such as `#root .badge`
- Invalid `data-start` references to nonexistent clip IDs
- Seek-order hazards that render differently on cold render workers: relative tween
values (`"+=..."`) whose property has a second concurrent writer
+251
View File
@@ -2387,6 +2387,257 @@ describe("composition rules", () => {
expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
});
it.each([
"#root .badge",
"#root .badge.zoomed",
"#root #badge",
"#root [data-badge]",
"#root > .badge",
".canvas .badge",
"body .badge",
"#root > *",
":is(#root, body) .badge",
":is(.missing, #root .badge)",
'#root[data-label="main canvas"] > .badge',
"#root::before",
"#root:is(span.zoomed)",
"#root:is(.badge::before)",
"#root :is(.badge.zoomed)",
"#root:is(#root .canvas)",
":is(span#root.zoomed).active",
":is(#root .badge).active",
":is(#root::before).active",
])("allows descendant or pseudo-element zoom selected by %s", async (selector) => {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
${selector} { zoom: 2; }
</style></head><body>
<div id="root" class="canvas" data-label="main canvas" data-composition-id="main" data-width="800" data-height="400" data-duration="5">
<div class="badge"></div>
</div>
</body></html>`);
expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
});
it.each([
"body > #root",
"body > *",
"html body .canvas",
":is(.missing, #root)",
":where(.missing, .canvas)",
"#root:not(.badge)",
".canvas:has(.badge)",
".badge::before, #root",
])("keeps canvas zoom selected by %s blocking", async (selector) => {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
${selector} { zoom: 2; }
</style></head><body>
<div id="root" class="canvas" data-composition-id="main" data-width="800" data-height="400" data-duration="5">
<div class="badge"></div>
</div>
</body></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
});
it.each([
["#root.zoomed", 'root.classList.add("zoomed")'],
[":is(#root.zoomed).active", 'root.classList.add("zoomed", "active")'],
[
":where(.canvas.zoomed)[data-active]",
'root.classList.add("zoomed"); root.dataset.active = ""',
],
[":is(#root[data-zoomed]).active", 'root.dataset.zoomed = ""; root.classList.add("active")'],
["#root:is(.zoomed)", 'root.classList.add("zoomed")'],
["#root:where([data-zoomed])", 'root.dataset.zoomed = ""'],
[".canvas:is(.zoomed)", 'root.classList.add("zoomed")'],
[":is(#root).zoomed", 'root.classList.add("zoomed")'],
["#root:is(:where(.zoomed))", 'root.classList.add("zoomed")'],
[".canvas.zoomed", 'root.classList.add("zoomed")'],
[".zoomed.canvas", 'root.classList.add("zoomed")'],
[".canvas#zoomed", 'root.id = "zoomed"'],
["#root[data-zoomed]", 'root.dataset.zoomed = ""'],
["body > div.zoomed", 'root.classList.add("zoomed")'],
["body > div#zoomed", 'root.id = "zoomed"'],
["body > div[data-zoomed]", 'root.dataset.zoomed = ""'],
])("retains canvas protection for runtime identity in %s", async (selector, mutation) => {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
${selector} { zoom: 2; }
</style></head><body>
<div id="root" class="canvas" data-composition-id="main" data-width="800" data-height="400" data-duration="5"></div>
<script>const root = document.getElementById("root"); ${mutation};</script>
</body></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
});
it("does not assume a head composition root is a child of body", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html>
<head id="root" data-composition-id="main" data-width="800" data-height="400" data-duration="5">
<style>head { display: block; } html > #root { zoom: 2; }</style>
</head>
</html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
});
it("does not assume a template composition root is a child of body", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><head>
<style>head, template { display: block; } html > head > #root { zoom: 2; }</style>
<template id="root" data-composition-id="main" data-width="800" data-height="400" data-duration="5"></template>
</head><div></div></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
});
it.each([
'<html><head><style>CSS</style></head><body data-composition-id="main" data-width="800" data-height="400" data-duration="5"><div class="badge"></div></body></html>',
'<template><style>CSS</style><div data-composition-id="main" data-width="800" data-height="400" data-duration="5"><div class="badge"></div></div></template>',
])("matches the lint context's root in body and template sources", async (root) => {
for (const [selector, expected] of [
["[data-composition-id] .badge", false],
["[data-composition-id]", true],
["body", true],
] as const) {
const result = await lintHyperframeHtml(root.replace("CSS", `${selector} { zoom: 2; }`));
expect(codes(result).includes("root_zoom_rescales_a_fixed_canvas")).toBe(expected);
}
});
it("treats optional document tags like their explicit equivalents", async () => {
const root =
'<div id="root" data-composition-id="main" data-width="800" data-height="400" data-duration="5"><div class="badge"></div></div>';
for (const source of [
`<html><head><style>CSS</style></head><body>${root}</body></html>`,
`<html><head><style>CSS</style></head>${root}</html>`,
`<html><head><title>Canvas</title><style>CSS</style>${root}</html>`,
`<html><head><title>Canvas</title><style>CSS</style><template><div></div></template><body>${root}</body></html>`,
`<html><style>CSS</style><body>${root}</body></html>`,
`<html><style>CSS</style>${root}</html>`,
]) {
for (const [selector, expected] of [
["#root:first-child", true],
["body:nth-child(2) > #root", true],
["#root:first-child .badge", false],
] as const) {
const result = await lintHyperframeHtml(
source.replace("CSS", `${selector} { zoom: 2; }`),
);
expect(codes(result).includes("root_zoom_rescales_a_fixed_canvas"), source).toBe(
expected,
);
}
}
});
it.each([
"#root:lang(en)",
"#root:dir(ltr)",
"#root:nth-child(1 of .canvas)",
":is(#root, :unknown)",
])("retains canvas protection for unsupported static selectors: %s", async (selector) => {
const result = await lintHyperframeHtml(`<html lang="en" dir="ltr"><head><style>
${selector} { zoom: 2; }
</style></head><body>
<div id="root" class="canvas" data-composition-id="main" data-width="800" data-height="400" data-duration="5"></div>
</body></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
});
it("keeps table-dependent canvas zoom blocking with optional tbody tags", async () => {
for (const table of [
"<table><tr><td>Cell</td></tr></table>",
"<table><tbody><tr><td>Cell</td></tr></tbody></table>",
]) {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
#root:has(tbody) { zoom: 2; }
</style></head><body>
<div id="root" data-composition-id="main" data-width="800" data-height="400" data-duration="5">${table}</div>
</body></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
}
});
it("keeps SVG canvas zoom blocking with case-sensitive attributes", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
body > svg[viewBox] { zoom: 2; }
</style></head><body>
<svg id="root" viewBox="0 0 800 400" data-composition-id="main" data-width="800" data-height="400" data-duration="5"></svg>
</body></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
});
it.each([".canvas #ROOT", ".canvas .BADGE", ".canvas :is(.BADGE, .missing)"])(
"keeps quirks-mode canvas zoom blocking for %s",
async (selector) => {
const result = await lintHyperframeHtml(`<html><head><style>
${selector} { zoom: 2; }
</style></head><body class="canvas">
<div id="root" class="canvas badge" data-composition-id="main" data-width="800" data-height="400" data-duration="5"></div>
</body></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
},
);
it("does not invent a canvas hit for a false body position", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
body:nth-child(3) > #root { zoom: 2; }
</style></head><body>
<div id="root" data-composition-id="main" data-width="800" data-height="400" data-duration="5"></div>
</body></html>`);
expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
});
it("does not treat a relative selector as an unconditional canvas hit", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
> #root { zoom: 2; }
</style></head><body>
<div id="root" data-composition-id="main" data-width="800" data-height="400" data-duration="5"></div>
</body></html>`);
expect(codes(result)).not.toContain("root_zoom_rescales_a_fixed_canvas");
});
it("preserves uncertainty about stylesheet context before adding canvas hits", async () => {
for (const selector of ["[data-review-zoom]", "body:nth-child(2) > #root"]) {
for (const [style, expected] of [
[`<style>.unused { color: red; } ${selector} { zoom: 2; }</style>`, true],
[
`<style>@namespace url("http://www.w3.org/2000/svg"); .unused { color: red; } ${selector} { zoom: 2; }</style>`,
false,
],
[`<style>@media print { ${selector} { zoom: 2; } }</style>`, false],
[`<style media="print">${selector} { zoom: 2; }</style>`, false],
] as const) {
const result = await lintHyperframeHtml(`<!doctype html><html><head>${style}</head><body>
<div id="root" data-review-zoom data-composition-id="main" data-width="800" data-height="400" data-duration="5">
<svg id="badge" data-review-zoom width="40" height="40"></svg>
</div>
</body></html>`);
expect(codes(result).includes("root_zoom_rescales_a_fixed_canvas"), style).toBe(expected);
}
}
});
it("keeps namespace context local to its stylesheet", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><head>
<style>@namespace url("http://www.w3.org/2000/svg"); .unused { color: red; }</style>
<style>[data-review-zoom] { zoom: 2; }</style>
</head><body>
<div id="root" data-review-zoom data-composition-id="main" data-width="800" data-height="400" data-duration="5"></div>
</body></html>`);
expect(codes(result)).toContain("root_zoom_rescales_a_fixed_canvas");
});
it("stays silent on identity zoom values", async () => {
for (const value of ["1", "1.0", "100%", "normal", "unset", "initial"]) {
const result = await lintHyperframeHtml(`<!doctype html><html><head><style>
+222 -32
View File
@@ -1,4 +1,5 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context";
import selectorParser from "postcss-selector-parser";
import {
findHtmlTag,
extractTimelineRegistryKeys,
@@ -272,22 +273,210 @@ function firstRescalingZoom(css: string): string | null {
return null;
}
/**
* Does this selector's leftmost compound target the canvas itself — the
* composition root, or an ancestor of it? A zoom on a DESCENDANT is ordinary
* authoring and renders exactly as authored; only the canvas-level one
* desynchronises painted content from the declared frame.
*/
function targetsCanvasRoot(
selector: string,
rootId: string | null,
rootClasses: string[],
): boolean {
function canvasScopeCandidate(selector: string, root: ReturnType<typeof canvasIdentity>): boolean {
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
const bare = leftmost.toLowerCase();
if (bare === "html" || bare === "body" || bare === ":root" || bare === "*") return true;
if (rootId && leftmostCompoundId(selector) === rootId) return true;
return leftmostCompoundClasses(selector).some((cls) => rootClasses.includes(cls));
if (["html", "body", ":root", "*"].includes(leftmost.toLowerCase())) return true;
if (root.id && leftmostCompoundId(selector) === root.id) return true;
return leftmostCompoundClasses(selector).some((name) => root.classes.includes(name));
}
// Unknown predicates retain the conservative canvas guard.
type CanvasMatch = boolean | undefined;
type CanvasMatchResult = { match: CanvasMatch; hasIdentity: boolean };
function canvasIdentity(tag: Pick<OpenTag, "name" | "raw">) {
return {
name: tag.name,
raw: tag.raw,
id: readDecodedAttr(tag.raw, "id"),
classes: (readDecodedAttr(tag.raw, "class") ?? "").split(/[\t\n\f\r ]+/),
};
}
type CanvasElement = ReturnType<typeof canvasIdentity> & {
parent: CanvasElement | null | undefined;
};
function anyCanvasMatch(matches: CanvasMatch[]): CanvasMatch {
if (matches.includes(true)) return true;
return matches.includes(undefined) ? undefined : false;
}
function allCanvasMatch(matches: CanvasMatch[]): CanvasMatch {
if (matches.includes(false)) return false;
return matches.includes(undefined) ? undefined : true;
}
function canvasTokenMatch(tokens: string[], value: string): CanvasMatch {
if (tokens.includes(value)) return true;
const fold = (token: string) => token.replace(/[A-Z]/g, (char) => char.toLowerCase());
return tokens.some((token) => fold(token) === fold(value)) ? undefined : false;
}
function canvasPseudoMatch(
node: selectorParser.Pseudo,
element: CanvasElement,
hasIdentity: boolean,
): CanvasMatchResult {
const name = node.value.toLowerCase();
if (name.startsWith("::") || [":before", ":after", ":first-letter", ":first-line"].includes(name))
return { match: false, hasIdentity: false };
if ([":is", ":where"].includes(name)) {
const branches = node.nodes.map((branch) =>
canvasPathMatch(branch.nodes, element, hasIdentity),
);
const match = anyCanvasMatch(branches.map((branch) => branch.match));
return { match, hasIdentity: match === true || branches.some((branch) => branch.hasIdentity) };
}
if (name === ":root") {
const match = element.name === "html";
return { match, hasIdentity: match };
}
if (name === ":nth-child" && element.name === "body") {
// Body follows head even when their tags are implicit.
// https://html.spec.whatwg.org/multipage/semantics.html#the-html-element
const argument = node.nodes
.map((branch) => branch.toString())
.join(",")
.trim();
if (/^[1-9]\d*$/.test(argument)) {
const match = Number(argument) === 2;
return { match, hasIdentity: match };
}
}
return { match: undefined, hasIdentity: false };
}
function canvasTagMatch(node: selectorParser.Tag, element: CanvasElement): CanvasMatchResult {
if (node.namespace !== undefined) return { match: undefined, hasIdentity: false };
if (node.value.toLowerCase() !== element.name.toLowerCase())
return { match: false, hasIdentity: false };
return { match: ["html", "body"].includes(element.name) ? true : undefined, hasIdentity: true };
}
function canvasAttributeMatch(node: selectorParser.Attribute, element: CanvasElement): CanvasMatch {
if (
node.namespace === undefined &&
node.operator === undefined &&
/^data-[a-z0-9_-]+$/.test(node.attribute)
)
return readDecodedAttr(element.raw, node.attribute) !== null;
return undefined;
}
function canvasNodeMatch(
node: selectorParser.Node,
element: CanvasElement,
hasIdentity: boolean,
): CanvasMatchResult {
switch (node.type) {
case "id": {
const match = canvasTokenMatch(element.id === null ? [] : [element.id], node.value);
return { match, hasIdentity: match === true };
}
case "class": {
const match = canvasTokenMatch(element.classes, node.value);
return { match, hasIdentity: match === true };
}
case "tag":
return canvasTagMatch(node, element);
case "universal":
return { match: node.toString().trim() === "*" ? true : undefined, hasIdentity: false };
case "attribute":
return { match: canvasAttributeMatch(node, element), hasIdentity: false };
case "pseudo":
return canvasPseudoMatch(node, element, hasIdentity);
default:
return { match: undefined, hasIdentity: false };
}
}
function canvasAncestorMatch(
nodes: selectorParser.Node[],
relation: string,
element: CanvasElement,
): CanvasMatch {
if (!["", ">"].includes(relation)) return undefined;
const matches: CanvasMatch[] = [];
let parent = element.parent;
while (parent) {
matches.push(canvasPathMatch(nodes, parent).match);
if (relation === ">") break;
parent = parent.parent;
}
if (parent === undefined) matches.push(undefined);
return anyCanvasMatch(matches);
}
function canvasPathMatch(
nodes: selectorParser.Node[],
element: CanvasElement,
inheritedIdentity = false,
): CanvasMatchResult {
let split = -1;
let relation = "";
for (const [index, node] of nodes.entries()) {
if (node.type === "combinator") {
split = index;
relation = node.value.trim();
}
}
const subjectNodes = nodes.slice(split + 1);
if (subjectNodes.length === 0) return { match: undefined, hasIdentity: false };
const matches = subjectNodes.map((node) => canvasNodeMatch(node, element, inheritedIdentity));
const hasIdentity = inheritedIdentity || matches.some((result) => result.hasIdentity);
const subject = allCanvasMatch(
subjectNodes.map((node, index) => {
const match = matches[index]?.match;
if (!hasIdentity || match !== false) return match;
// Scripts can change the remaining identity tokens on a known canvas element.
if (["id", "class", "attribute"].includes(node.type)) return undefined;
if (node.type === "pseudo" && !inheritedIdentity)
return canvasNodeMatch(node, element, true).match;
return match;
}),
);
const match =
subject === false || split === -1
? subject
: allCanvasMatch([subject, canvasAncestorMatch(nodes.slice(0, split), relation, element)]);
return { match, hasIdentity: match !== false && hasIdentity };
}
function canvasElements(rootTag: OpenTag, tags: OpenTag[]) {
const root = canvasIdentity(rootTag);
const html: CanvasElement = {
...(root.name === "html"
? root
: canvasIdentity(findHtmlTag(tags) ?? { name: "html", raw: "<html>" })),
parent: null,
};
const body: CanvasElement = {
...(root.name === "body"
? root
: canvasIdentity(tags.find((tag) => tag.name === "body") ?? { name: "body", raw: "<body>" })),
parent: html,
};
const elements = [html, body];
if (!["html", "body"].includes(root.name))
elements.push({ ...root, parent: ["head", "template"].includes(root.name) ? undefined : body });
return { root, elements };
}
function canvasSelectorMatcher(rootTag: OpenTag, tags: OpenTag[]) {
let canvas: ReturnType<typeof canvasElements> | undefined;
return (selector: selectorParser.Selector, allowNewHits: boolean): boolean => {
const { root, elements } = (canvas ??= canvasElements(rootTag, tags));
const match = anyCanvasMatch(
elements.map((element) => canvasPathMatch(selector.nodes, element).match),
);
if (match === false) return false;
if (match === true && allowNewHits) return true;
return selector
.toString()
.split(",")
.some((part) => canvasScopeCandidate(part, root));
};
}
/**
@@ -325,12 +514,15 @@ function inlineCanvasZoomHits(rootTag: OpenTag, tags: OpenTag[]): CanvasZoomHit[
*/
function firstCanvasSelector(
header: string,
rootId: string | null,
rootClasses: string[],
matchesCanvas: (selector: selectorParser.Selector) => boolean,
): string | null {
for (const selector of header.split(",")) {
const trimmed = selector.trim();
if (trimmed && targetsCanvasRoot(trimmed, rootId, rootClasses)) return trimmed;
try {
for (const selector of selectorParser().astSync(header).nodes) {
const trimmed = selector.toString().trim();
if (matchesCanvas(selector)) return trimmed;
}
} catch {
return null;
}
return null;
}
@@ -339,15 +531,14 @@ function firstCanvasSelector(
function canvasZoomInRule(
header: string,
body: string,
rootId: string | null,
rootClasses: string[],
matchesCanvas: (selector: selectorParser.Selector) => boolean,
): CanvasZoomHit | null {
const trimmedHeader = header.trim();
// An at-rule's "header" is `@media ...`, not a selector list.
if (!trimmedHeader || trimmedHeader.startsWith("@")) return null;
const value = firstRescalingZoom(body);
if (!value) return null;
const selector = firstCanvasSelector(trimmedHeader, rootId, rootClasses);
const selector = firstCanvasSelector(trimmedHeader, matchesCanvas);
if (!selector) return null;
return {
where: `\`${selector}\``,
@@ -358,16 +549,19 @@ function canvasZoomInRule(
function stylesheetCanvasZoomHits(
styles: ExtractedBlock[],
rootId: string | null,
rootClasses: string[],
matchesCanvas: ReturnType<typeof canvasSelectorMatcher>,
): CanvasZoomHit[] {
const hits: CanvasZoomHit[] = [];
for (const style of styles) {
const ruleWithBody = /([^{}]+)\{([^{}]*)\}/g;
const content = stripCssComments(style.content);
// At-rules and style attributes can restrict selector applicability.
const allowNewHits = style.attrs.trim() === "" && !content.includes("@");
let match: RegExpExecArray | null;
while ((match = ruleWithBody.exec(content)) !== null) {
const hit = canvasZoomInRule(match[1] ?? "", match[2] ?? "", rootId, rootClasses);
const hit = canvasZoomInRule(match[1] ?? "", match[2] ?? "", (selector) =>
matchesCanvas(selector, allowNewHits),
);
if (hit) hits.push(hit);
}
}
@@ -378,12 +572,10 @@ function canvasZoomHits(
rootTag: OpenTag,
tags: OpenTag[],
styles: ExtractedBlock[],
rootId: string | null,
rootClasses: string[],
): CanvasZoomHit[] {
return [
...inlineCanvasZoomHits(rootTag, tags),
...stylesheetCanvasZoomHits(styles, rootId, rootClasses),
...stylesheetCanvasZoomHits(styles, canvasSelectorMatcher(rootTag, tags)),
];
}
@@ -1619,9 +1811,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// fired on every `zoom` would be flagging correct authoring.
({ rootTag, tags, styles }) => {
if (!rootTag) return [];
const rootId = readAttr(rootTag.raw, "id");
const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
const hits = canvasZoomHits(rootTag, tags, styles, rootId, rootClasses);
const hits = canvasZoomHits(rootTag, tags, styles);
// `noUncheckedIndexedAccess` makes hits[0] `T | undefined`, and a length
// check does not narrow it — guard on the element itself.
+2 -2
View File
@@ -24,8 +24,8 @@
"unpacked": 3645432
},
"@hyperframes/lint": {
"packed": 168745,
"unpacked": 654607
"packed": 179338,
"unpacked": 695173
},
"@hyperframes/parsers": {
"packed": 191000,