authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-03 13:28:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 17:28:08-03:00
log0cfa113be597af3a1b2fe905a58a5b583f8089b8
treed9275e66d09a94a5e036c73d45aa49cb09dbe54a
parentfffe6e6c82c12769e0cbc4d89a23ec845c5abed1
signature Signed by PGP key B5690EEEBB952194

chore(lint): flag :has() variants that restyle the whole page (#15274)


4 files changed, 306 insertions(+), 0 deletions(-)

oxlint-plugin-t3code/index.ts+2
...@@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te...@@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te
8import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";8import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts";
9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";9import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
10import noTestInLoop from "./rules/no-test-in-loop.ts";10import noTestInLoop from "./rules/no-test-in-loop.ts";
11import noUnscopedHas from "./rules/no-unscoped-has.ts";
1112
12export default definePlugin({13export default definePlugin({
13 meta: {14 meta: {
...@@ -22,5 +23,6 @@ export default definePlugin({...@@ -22,5 +23,6 @@ export default definePlugin({
22 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,23 "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches,
23 "no-native-title-tooltip": noNativeTitleTooltip,24 "no-native-title-tooltip": noNativeTitleTooltip,
24 "no-test-in-loop": noTestInLoop,25 "no-test-in-loop": noTestInLoop,
26 "no-unscoped-has": noUnscopedHas,
25 },27 },
26});28});
oxlint-plugin-t3code/rules/no-unscoped-has.test.ts created+124
...@@ -0,0 +1,124 @@
1/* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */
2import { assert, describe } from "@effect/vitest";
3
4import { createOxlintRuleHarness } from "../test/utils.ts";
5
6const rule = createOxlintRuleHarness("t3code/no-unscoped-has", {
7 filename: "fixture.tsx",
8});
9
10describe("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.valid(
39 "ignores :has() inside an arbitrary value",
40 `const className = "before:content-[':has(foo)']";`,
41 );
42
43 rule.valid(
44 "allows a negated :has() on the element itself",
45 `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`,
46 );
47
48 rule.valid(
49 "allows :has() on a group or peer element",
50 `const className = "group-[:has(input)]:p-2 peer-[:has(input)]:p-2 group-[&:has(input)]/row:p-2";`,
51 );
52
53 rule.valid(
54 "allows a selector list whose own branch is anchored",
55 `const className = "[:is(.a,.b):has(x)_&]:p-2 [&:not(.a,:has(x))]:p-2";`,
56 );
57
58 rule.valid(
59 "ignores :has() text in quoted attribute values",
60 `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`,
61 );
62
63 rule.valid(
64 "allows a selector without & on the element itself",
65 `const className = "[:has(>input)]:p-2 not-[:has(>[data-slot=icon])]:ps-2";`,
66 );
67
68 rule.invalid(
69 "reports a sibling :has() with nothing anchoring it",
70 `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`,
71 (output) => {
72 assert.match(output, /Anchor the :has\(\)/);
73 },
74 );
75
76 rule.invalid(
77 "reports a descendant :has() with nothing anchoring it",
78 `const className = cn("p-2", "[&_:has(>input)]:gap-1");`,
79 );
80
81 rule.invalid(
82 "reports a universal :has() ancestor",
83 "const className = `flex [*:has([data-open])_&]:hidden`;",
84 );
85
86 rule.invalid(
87 "reports :has() whose only anchor is negated",
88 `const className = "[*:not(.safe):has([data-open])_&]:hidden";`,
89 );
90
91 rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`);
92
93 rule.invalid(
94 "reports a selector-list branch borrowing another branch's anchor",
95 `const className = "[.safe,:has(input)_&]:p-2";`,
96 );
97
98 rule.invalid(
99 "reports an unanchored branch inside :is()",
100 `const className = "[&_:is(.safe,:has(input))]:p-2";`,
101 );
102
103 rule.invalid(
104 "reports an unanchored :has() inside a named group variant",
105 `const className = "group-[&_:has(x)]/row:p-2";`,
106 );
107
108 rule.invalid(
109 "reports a :has() on an ancestor of a selector without &",
110 `const className = "[:has(x)_.foo]:p-2";`,
111 );
112
113 rule.invalid(
114 "reports a :has() on an ancestor of a group",
115 `const className = "group-[:has(x)_.y]:p-2";`,
116 );
117
118 rule.invalid("reports an in-* ancestor :has()", `const className = "in-[:has(x)]:p-2";`);
119
120 rule.invalid(
121 "reports :has() anchored to the document root",
122 `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`,
123 );
124});
oxlint-plugin-t3code/rules/no-unscoped-has.ts created+179
...@@ -0,0 +1,179 @@
1import { defineRule } from "@oxlint/plugins";
2
3const COMBINATOR_PATTERN = /[\s>+~]/u;
4// A class, id, attribute, or leading tag narrows a compound. Negated ones don't,
5// so `:not(...)` is removed before this check.
6const NARROWING_PATTERN = /[.#[]|^[a-z]/iu;
7const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu;
8
9// group-[...] and peer-[...] match the .group/.peer element, in-[...] matches an
10// ancestor, and data-[...] and aria-[...] hold attribute values, not selectors.
11const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u;
12const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u;
13const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u;
14const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu;
15
16// A variant's closing bracket is followed by ":" or a group/peer name like "/row:".
17const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u;
18
19/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */
20function variantGroups(token: string): { prefix: string; group: string }[] {
21 const groups: { prefix: string; group: string }[] = [];
22 let depth = 0;
23 let start = -1;
24 for (let index = 0; index < token.length; index++) {
25 const char = token[index];
26 if (char === "[") {
27 if (depth === 0) start = index + 1;
28 depth++;
29 } else if (char === "]" && depth > 0) {
30 depth--;
31 if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) {
32 groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) });
33 }
34 }
35 }
36 return groups;
37}
38
39/** Index of the ")" closing the "(" at `open`, or the selector's length. */
40function closingParen(selector: string, open: number): number {
41 let depth = 0;
42 for (let index = open; index < selector.length; index++) {
43 if (selector[index] === "(") depth++;
44 else if (selector[index] === ")" && --depth === 0) return index;
45 }
46 return selector.length;
47}
48
49/**
50 * Whether nothing after `from` in the enclosing branch is joined by a combinator,
51 * i.e. the element ending at `from` is the subject that its wrapper matches.
52 */
53function isWrapperSubject(selector: string, from: number): boolean {
54 let depth = 0;
55 for (let index = from; index < selector.length; index++) {
56 const char = selector[index] ?? "";
57 if (char === "(") depth++;
58 else if (char === ")") {
59 if (depth === 0) return true;
60 depth--;
61 } else if (depth > 0) continue;
62 else if (char === ",") return true;
63 else if (COMBINATOR_PATTERN.test(char)) {
64 const next = selector.slice(index).trimStart()[0];
65 if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue;
66 return false;
67 }
68 }
69 return true;
70}
71
72/** The compound selector each `:has(` in `selector` is attached to. */
73function hasCompounds(selector: string): string[] {
74 const compounds: string[] = [];
75 let index = selector.indexOf(":has(");
76 while (index !== -1) {
77 let compound = "";
78 let depth = 0;
79 let subjectEnd = closingParen(selector, index + ":has".length) + 1;
80 // Other branches of a selector list are skipped until the wrapper that holds
81 // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/
82 // :where(); the compound outside that wrapper applies only when the :has()
83 // is in the wrapper's subject position.
84 let skippingBranch = false;
85 for (let position = index - 1; position >= 0; position--) {
86 const char = selector[position] ?? "";
87 if (char === ")") depth++;
88 else if (char === "(") {
89 if (depth > 0) depth--;
90 else {
91 if (!isWrapperSubject(selector, subjectEnd)) break;
92 skippingBranch = false;
93 subjectEnd = closingParen(selector, position) + 1;
94 }
95 } else if (depth === 0 && char === ",") {
96 skippingBranch = true;
97 continue;
98 } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break;
99 if (!skippingBranch) compound = char + compound;
100 }
101 compounds.push(compound);
102 index = selector.indexOf(":has(", index + 1);
103 }
104 return compounds;
105}
106
107/** `compound` without any `:not(...)`, including one left open around the `:has()`. */
108function withoutNegations(compound: string): string {
109 let result = "";
110 let index = 0;
111 while (index < compound.length) {
112 if (!compound.startsWith(":not(", index)) {
113 result += compound[index];
114 index++;
115 continue;
116 }
117 let depth = 0;
118 for (index += ":not".length; index < compound.length; index++) {
119 if (compound[index] === "(") depth++;
120 else if (compound[index] === ")" && --depth === 0) break;
121 }
122 index++;
123 }
124 return result;
125}
126
127/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */
128function findUnscopedHasVariants(text: string): string[] {
129 // Selectors are ASCII case-insensitive.
130 if (!text.toLowerCase().includes(":has(")) return [];
131 const offenders: string[] = [];
132 for (const token of text.split(/\s+/u)) {
133 for (const { prefix, group } of variantGroups(token)) {
134 if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue;
135 // Tailwind writes spaces as "_", and "&" is the element carrying the class.
136 // A selector without "&" applies to that element, as `&:is(...)`.
137 const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " ");
138 const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self";
139 const selector = ANCESTOR_PREFIX_PATTERN.test(prefix)
140 ? `:is(${relative.replaceAll("&", "*")}) .self`
141 : relative.includes("&")
142 ? relative.replaceAll("&", owner)
143 : `${owner}:is(${relative})`;
144 const unscoped = hasCompounds(selector).some((compound) => {
145 const anchor = withoutNegations(compound);
146 return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor);
147 });
148 if (unscoped) offenders.push(`[${group}]`);
149 }
150 }
151 return offenders;
152}
153
154export default defineRule({
155 meta: {
156 type: "problem",
157 docs: {
158 description:
159 "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.",
160 },
161 },
162 create(context) {
163 const message = (variant: string) =>
164 `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.`;
165 return {
166 Literal(node) {
167 if (typeof node.value !== "string") return;
168 for (const variant of findUnscopedHasVariants(node.value)) {
169 context.report({ node, message: message(variant) });
170 }
171 },
172 TemplateElement(node) {
173 for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) {
174 context.report({ node, message: message(variant) });
175 }
176 },
177 };
178 },
179});
vite.config.ts+1
...@@ -168,6 +168,7 @@ export default defineConfig({...@@ -168,6 +168,7 @@ export default defineConfig({
168 "t3code/no-manual-effect-runtime-in-tests": "error",168 "t3code/no-manual-effect-runtime-in-tests": "error",
169 "t3code/no-native-title-tooltip": "error",169 "t3code/no-native-title-tooltip": "error",
170 "t3code/no-test-in-loop": "error",170 "t3code/no-test-in-loop": "error",
171 "t3code/no-unscoped-has": "error",
171 "t3code/namespace-node-imports": "error",172 "t3code/namespace-node-imports": "error",
172 },173 },
173 overrides: [174 overrides: [