Skip to content

Commit f001ecd

Browse files
committed
chore(lint): flag :has() variants that restyle the whole page
Tailwind arbitrary variants like [&+:has(x)_...] attach :has() to nothing in particular, so Chrome evaluates it on every ancestor up to <html> and any DOM change then restyles the entire document (see the ComposerBanner fix). The new t3code/no-unscoped-has rule requires each :has() in an arbitrary variant to be anchored to a class, attribute, id or tag below the document root.
1 parent c26d21d commit f001ecd

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

‎oxlint-plugin-t3code/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te
88
import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
99
import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
1010
import noTestInLoop from "./rules/no-test-in-loop.ts";
11+
import noUnscopedHas from "./rules/no-unscoped-has.ts";
1112

1213
export default definePlugin({
1314
meta: {
@@ -22,5 +23,6 @@ export default definePlugin({
2223
"no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
2324
"no-native-title-tooltip": noNativeTitleTooltip,
2425
"no-test-in-loop": noTestInLoop,
26+
"no-unscoped-has": noUnscopedHas,
2527
},
2628
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */
2+
import { assert, describe } from "@effect/vitest";
3+
4+
import { createOxlintRuleHarness } from "../test/utils.ts";
5+
6+
const rule = createOxlintRuleHarness("t3code/no-unscoped-has", {
7+
filename: "fixture.tsx",
8+
});
9+
10+
describe("t3code/no-unscoped-has", () => {
11+
rule.valid(
12+
"allows :has() on the element itself",
13+
`const className = "[&:has([data-slot=icon])]:ps-2";`,
14+
);
15+
16+
rule.valid(
17+
"allows :has() anchored to an attribute",
18+
`const className = "[&+[data-chat-composer-form]:has(>[data-slot=banner])]:mt-0";`,
19+
);
20+
21+
rule.valid(
22+
"allows :has() inside :not() on the element itself",
23+
`const className = "[&:not(:has(+[data-slot=footer]))]:rounded-b-2xl";`,
24+
);
25+
26+
rule.valid(
27+
"allows built-in has-* variants",
28+
`const className = "has-[>[data-slot=icon]]:ps-2 group-has-[:checked]:opacity-100";`,
29+
);
30+
31+
rule.valid(
32+
"allows a sibling selector without :has()",
33+
`const className = "[&+*_[data-chat-composer-form]>[data-slot=attachment]]:before:rounded-none";`,
34+
);
35+
36+
rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`);
37+
38+
rule.invalid(
39+
"reports a sibling :has() with nothing anchoring it",
40+
`const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`,
41+
(output) => {
42+
assert.match(output, /Anchor the :has\(\)/);
43+
},
44+
);
45+
46+
rule.invalid(
47+
"reports a descendant :has() with nothing anchoring it",
48+
`const className = cn("p-2", "[&_:has(>input)]:gap-1");`,
49+
);
50+
51+
rule.invalid(
52+
"reports a universal :has() ancestor",
53+
"const className = `flex [*:has([data-open])_&]:hidden`;",
54+
);
55+
56+
rule.invalid(
57+
"reports :has() anchored to the document root",
58+
`const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`,
59+
);
60+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { defineRule } from "@oxlint/plugins";
2+
3+
const COMBINATOR_PATTERN = /[\s>+~]/u;
4+
// A class, id, attribute, or tag at the start of the compound narrows it.
5+
const NARROWING_PATTERN = /[.#[]|^[a-z]/iu;
6+
const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu;
7+
8+
/** Top-level `[...]` groups in a class token, i.e. Tailwind arbitrary variants and values. */
9+
function bracketGroups(token: string): string[] {
10+
const groups: string[] = [];
11+
let depth = 0;
12+
let start = -1;
13+
for (let index = 0; index < token.length; index++) {
14+
const char = token[index];
15+
if (char === "[") {
16+
if (depth === 0) start = index + 1;
17+
depth++;
18+
} else if (char === "]" && depth > 0) {
19+
depth--;
20+
if (depth === 0) groups.push(token.slice(start, index));
21+
}
22+
}
23+
return groups;
24+
}
25+
26+
/** The compound selector each `:has(` in `selector` is attached to. */
27+
function hasCompounds(selector: string): string[] {
28+
const compounds: string[] = [];
29+
let index = selector.indexOf(":has(");
30+
while (index !== -1) {
31+
let start = index - 1;
32+
let depth = 0;
33+
// An unbalanced "(" means the :has() sits inside :not()/:is()/:where(),
34+
// so the compound outside that wrapper still applies.
35+
for (; start >= 0; start--) {
36+
const char = selector[start];
37+
if (char === ")") depth++;
38+
else if (char === "(") {
39+
if (depth > 0) depth--;
40+
} else if (depth === 0 && COMBINATOR_PATTERN.test(char ?? "")) break;
41+
}
42+
compounds.push(selector.slice(start + 1, index));
43+
index = selector.indexOf(":has(", index + 1);
44+
}
45+
return compounds;
46+
}
47+
48+
/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */
49+
function findUnscopedHasVariants(text: string): string[] {
50+
if (!text.includes(":has(")) return [];
51+
const offenders: string[] = [];
52+
for (const token of text.split(/\s+/u)) {
53+
for (const group of bracketGroups(token)) {
54+
if (!group.includes(":has(")) continue;
55+
// Tailwind writes spaces as "_", and "&" is the element carrying the class.
56+
const selector = group.replaceAll("_", " ").replaceAll("&", ".self");
57+
const unscoped = hasCompounds(selector).some(
58+
(compound) => !NARROWING_PATTERN.test(compound) || ROOT_COMPOUND_PATTERN.test(compound),
59+
);
60+
if (unscoped) offenders.push(`[${group}]`);
61+
}
62+
}
63+
return offenders;
64+
}
65+
66+
export default defineRule({
67+
meta: {
68+
type: "problem",
69+
docs: {
70+
description:
71+
"Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.",
72+
},
73+
},
74+
create(context) {
75+
const message = (variant: string) =>
76+
`Anchor the :has() in ${variant} to a class, attribute, or tag below the document root, e.g. [&+[data-x]_…] or a has-* variant. Chrome evaluates an unanchored :has() on every ancestor, so any DOM change then restyles the whole page.`;
77+
return {
78+
Literal(node) {
79+
if (typeof node.value !== "string") return;
80+
for (const variant of findUnscopedHasVariants(node.value)) {
81+
context.report({ node, message: message(variant) });
82+
}
83+
},
84+
TemplateElement(node) {
85+
for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) {
86+
context.report({ node, message: message(variant) });
87+
}
88+
},
89+
};
90+
},
91+
});

‎vite.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ export default defineConfig({
168168
"t3code/no-manual-effect-runtime-in-tests": "error",
169169
"t3code/no-native-title-tooltip": "error",
170170
"t3code/no-test-in-loop": "error",
171+
"t3code/no-unscoped-has": "error",
171172
"t3code/namespace-node-imports": "error",
172173
},
173174
overrides: [

0 commit comments

Comments
 (0)