Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/packages/lint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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
Expand Down
251 changes: 251 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Loading
Loading