From 0cfa113be597af3a1b2fe905a58a5b583f8089b8 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:28:08 -0700 Subject: [PATCH] chore(lint): flag :has() variants that restyle the whole page (#15274) --- oxlint-plugin-t3code/index.ts | 2 + .../rules/no-unscoped-has.test.ts | 124 ++++++++++++ oxlint-plugin-t3code/rules/no-unscoped-has.ts | 179 ++++++++++++++++++ vite.config.ts | 1 + 4 files changed, 306 insertions(+) create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.test.ts create mode 100644 oxlint-plugin-t3code/rules/no-unscoped-has.ts diff --git a/oxlint-plugin-t3code/index.ts b/oxlint-plugin-t3code/index.ts index f4669f588f6731ea10dca9a9eb7f7bcd77b69f57..855ba1e7d098304f31fec76897ed328df15c1b29 100644 --- a/oxlint-plugin-t3code/index.ts +++ b/oxlint-plugin-t3code/index.ts @@ -8,6 +8,7 @@ import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-te import noMobileUniwindThemeEscapeHatches from "./rules/no-mobile-uniwind-theme-escape-hatches.ts"; import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts"; import noTestInLoop from "./rules/no-test-in-loop.ts"; +import noUnscopedHas from "./rules/no-unscoped-has.ts"; export default definePlugin({ meta: { @@ -22,5 +23,6 @@ export default definePlugin({ "no-mobile-uniwind-theme-escape-hatches": noMobileUniwindThemeEscapeHatches, "no-native-title-tooltip": noNativeTitleTooltip, "no-test-in-loop": noTestInLoop, + "no-unscoped-has": noUnscopedHas, }, }); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..76f0e7c7159df287978f5c9af602e5c9edc720bd --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.test.ts @@ -0,0 +1,124 @@ +/* oxlint-disable t3code/no-unscoped-has -- the fixtures are invalid on purpose */ +import { assert, describe } from "@effect/vitest"; + +import { createOxlintRuleHarness } from "../test/utils.ts"; + +const rule = createOxlintRuleHarness("t3code/no-unscoped-has", { + filename: "fixture.tsx", +}); + +describe("t3code/no-unscoped-has", () => { + rule.valid( + "allows :has() on the element itself", + `const className = "[&:has([data-slot=icon])]:ps-2";`, + ); + + rule.valid( + "allows :has() anchored to an attribute", + `const className = "[&+[data-chat-composer-form]:has(>[data-slot=banner])]:mt-0";`, + ); + + rule.valid( + "allows :has() inside :not() on the element itself", + `const className = "[&:not(:has(+[data-slot=footer]))]:rounded-b-2xl";`, + ); + + rule.valid( + "allows built-in has-* variants", + `const className = "has-[>[data-slot=icon]]:ps-2 group-has-[:checked]:opacity-100";`, + ); + + rule.valid( + "allows a sibling selector without :has()", + `const className = "[&+*_[data-chat-composer-form]>[data-slot=attachment]]:before:rounded-none";`, + ); + + rule.valid("ignores prose mentioning :has()", `const note = "uses :has( for styling";`); + + rule.valid( + "ignores :has() inside an arbitrary value", + `const className = "before:content-[':has(foo)']";`, + ); + + rule.valid( + "allows a negated :has() on the element itself", + `const className = "[&:not(.collapsed):not(:has(>[data-slot=icon]))]:ps-2";`, + ); + + rule.valid( + "allows :has() on a group or peer element", + `const className = "group-[:has(input)]:p-2 peer-[:has(input)]:p-2 group-[&:has(input)]/row:p-2";`, + ); + + rule.valid( + "allows a selector list whose own branch is anchored", + `const className = "[:is(.a,.b):has(x)_&]:p-2 [&:not(.a,:has(x))]:p-2";`, + ); + + rule.valid( + "ignores :has() text in quoted attribute values", + `const className = "data-[foo='_:has(x)']:p-2 [&_[data-query='_:has(foo)']]:p-2";`, + ); + + rule.valid( + "allows a selector without & on the element itself", + `const className = "[:has(>input)]:p-2 not-[:has(>[data-slot=icon])]:ps-2";`, + ); + + rule.invalid( + "reports a sibling :has() with nothing anchoring it", + `const className = "[&+:has([data-chat-composer-form])_[data-chat-composer-form]]:before:rounded-none";`, + (output) => { + assert.match(output, /Anchor the :has\(\)/); + }, + ); + + rule.invalid( + "reports a descendant :has() with nothing anchoring it", + `const className = cn("p-2", "[&_:has(>input)]:gap-1");`, + ); + + rule.invalid( + "reports a universal :has() ancestor", + "const className = `flex [*:has([data-open])_&]:hidden`;", + ); + + rule.invalid( + "reports :has() whose only anchor is negated", + `const className = "[*:not(.safe):has([data-open])_&]:hidden";`, + ); + + rule.invalid("reports uppercase :HAS()", `const className = "[*:HAS([data-open])_&]:hidden";`); + + rule.invalid( + "reports a selector-list branch borrowing another branch's anchor", + `const className = "[.safe,:has(input)_&]:p-2";`, + ); + + rule.invalid( + "reports an unanchored branch inside :is()", + `const className = "[&_:is(.safe,:has(input))]:p-2";`, + ); + + rule.invalid( + "reports an unanchored :has() inside a named group variant", + `const className = "group-[&_:has(x)]/row:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a selector without &", + `const className = "[:has(x)_.foo]:p-2";`, + ); + + rule.invalid( + "reports a :has() on an ancestor of a group", + `const className = "group-[:has(x)_.y]:p-2";`, + ); + + rule.invalid("reports an in-* ancestor :has()", `const className = "in-[:has(x)]:p-2";`); + + rule.invalid( + "reports :has() anchored to the document root", + `const className = "[body:has([data-dialog-open])_&]:overflow-hidden";`, + ); +}); diff --git a/oxlint-plugin-t3code/rules/no-unscoped-has.ts b/oxlint-plugin-t3code/rules/no-unscoped-has.ts new file mode 100644 index 0000000000000000000000000000000000000000..088505b3e0cf2ab48389e7136620d85f8ca3e9c7 --- /dev/null +++ b/oxlint-plugin-t3code/rules/no-unscoped-has.ts @@ -0,0 +1,179 @@ +import { defineRule } from "@oxlint/plugins"; + +const COMBINATOR_PATTERN = /[\s>+~]/u; +// A class, id, attribute, or leading tag narrows a compound. Negated ones don't, +// so `:not(...)` is removed before this check. +const NARROWING_PATTERN = /[.#[]|^[a-z]/iu; +const ROOT_COMPOUND_PATTERN = /^(?:html|body|:root)(?![\w-])/iu; + +// group-[...] and peer-[...] match the .group/.peer element, in-[...] matches an +// ancestor, and data-[...] and aria-[...] hold attribute values, not selectors. +const GROUP_PREFIX_PATTERN = /(?:^|:)(?:group|peer)-$/u; +const ANCESTOR_PREFIX_PATTERN = /(?:^|:)in-$/u; +const ATTRIBUTE_PREFIX_PATTERN = /(?:^|:)(?:data|aria)-$/u; +const QUOTED_PATTERN = /(["'])(?:\\.|(?!\1).)*\1/gu; + +// A variant's closing bracket is followed by ":" or a group/peer name like "/row:". +const VARIANT_END_PATTERN = /^(?:\/[\w-]+)?:/u; + +/** Tailwind arbitrary variants in a class token: top-level `[...]` groups used as variants. */ +function variantGroups(token: string): { prefix: string; group: string }[] { + const groups: { prefix: string; group: string }[] = []; + let depth = 0; + let start = -1; + for (let index = 0; index < token.length; index++) { + const char = token[index]; + if (char === "[") { + if (depth === 0) start = index + 1; + depth++; + } else if (char === "]" && depth > 0) { + depth--; + if (depth === 0 && VARIANT_END_PATTERN.test(token.slice(index + 1))) { + groups.push({ prefix: token.slice(0, start - 1), group: token.slice(start, index) }); + } + } + } + return groups; +} + +/** Index of the ")" closing the "(" at `open`, or the selector's length. */ +function closingParen(selector: string, open: number): number { + let depth = 0; + for (let index = open; index < selector.length; index++) { + if (selector[index] === "(") depth++; + else if (selector[index] === ")" && --depth === 0) return index; + } + return selector.length; +} + +/** + * Whether nothing after `from` in the enclosing branch is joined by a combinator, + * i.e. the element ending at `from` is the subject that its wrapper matches. + */ +function isWrapperSubject(selector: string, from: number): boolean { + let depth = 0; + for (let index = from; index < selector.length; index++) { + const char = selector[index] ?? ""; + if (char === "(") depth++; + else if (char === ")") { + if (depth === 0) return true; + depth--; + } else if (depth > 0) continue; + else if (char === ",") return true; + else if (COMBINATOR_PATTERN.test(char)) { + const next = selector.slice(index).trimStart()[0]; + if (/\s/u.test(char) && (next === undefined || next === ")" || next === ",")) continue; + return false; + } + } + return true; +} + +/** The compound selector each `:has(` in `selector` is attached to. */ +function hasCompounds(selector: string): string[] { + const compounds: string[] = []; + let index = selector.indexOf(":has("); + while (index !== -1) { + let compound = ""; + let depth = 0; + let subjectEnd = closingParen(selector, index + ":has".length) + 1; + // Other branches of a selector list are skipped until the wrapper that holds + // them opens. An unbalanced "(" means the :has() sits inside :not()/:is()/ + // :where(); the compound outside that wrapper applies only when the :has() + // is in the wrapper's subject position. + let skippingBranch = false; + for (let position = index - 1; position >= 0; position--) { + const char = selector[position] ?? ""; + if (char === ")") depth++; + else if (char === "(") { + if (depth > 0) depth--; + else { + if (!isWrapperSubject(selector, subjectEnd)) break; + skippingBranch = false; + subjectEnd = closingParen(selector, position) + 1; + } + } else if (depth === 0 && char === ",") { + skippingBranch = true; + continue; + } else if (depth === 0 && !skippingBranch && COMBINATOR_PATTERN.test(char)) break; + if (!skippingBranch) compound = char + compound; + } + compounds.push(compound); + index = selector.indexOf(":has(", index + 1); + } + return compounds; +} + +/** `compound` without any `:not(...)`, including one left open around the `:has()`. */ +function withoutNegations(compound: string): string { + let result = ""; + let index = 0; + while (index < compound.length) { + if (!compound.startsWith(":not(", index)) { + result += compound[index]; + index++; + continue; + } + let depth = 0; + for (index += ":not".length; index < compound.length; index++) { + if (compound[index] === "(") depth++; + else if (compound[index] === ")" && --depth === 0) break; + } + index++; + } + return result; +} + +/** Arbitrary variants in `text` whose `:has()` is unanchored or anchored to the document root. */ +function findUnscopedHasVariants(text: string): string[] { + // Selectors are ASCII case-insensitive. + if (!text.toLowerCase().includes(":has(")) return []; + const offenders: string[] = []; + for (const token of text.split(/\s+/u)) { + for (const { prefix, group } of variantGroups(token)) { + if (ATTRIBUTE_PREFIX_PATTERN.test(prefix)) continue; + // Tailwind writes spaces as "_", and "&" is the element carrying the class. + // A selector without "&" applies to that element, as `&:is(...)`. + const relative = group.toLowerCase().replace(QUOTED_PATTERN, '""').replaceAll("_", " "); + const owner = GROUP_PREFIX_PATTERN.test(prefix) ? ".group" : ".self"; + const selector = ANCESTOR_PREFIX_PATTERN.test(prefix) + ? `:is(${relative.replaceAll("&", "*")}) .self` + : relative.includes("&") + ? relative.replaceAll("&", owner) + : `${owner}:is(${relative})`; + const unscoped = hasCompounds(selector).some((compound) => { + const anchor = withoutNegations(compound); + return !NARROWING_PATTERN.test(anchor) || ROOT_COMPOUND_PATTERN.test(anchor); + }); + if (unscoped) offenders.push(`[${group}]`); + } + } + return offenders; +} + +export default defineRule({ + meta: { + type: "problem", + docs: { + description: + "Disallow Tailwind arbitrary variants with a :has() that is not anchored to a class, attribute, id, or tag.", + }, + }, + create(context) { + const message = (variant: string) => + `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.`; + return { + Literal(node) { + if (typeof node.value !== "string") return; + for (const variant of findUnscopedHasVariants(node.value)) { + context.report({ node, message: message(variant) }); + } + }, + TemplateElement(node) { + for (const variant of findUnscopedHasVariants(node.value.cooked ?? node.value.raw)) { + context.report({ node, message: message(variant) }); + } + }, + }; + }, +}); diff --git a/vite.config.ts b/vite.config.ts index 97729f5784b3ed2a6fca530b14eb33bab0deb37a..0eee88e2ad0821287662ffdb17b6ee45afa8be0d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -168,6 +168,7 @@ export default defineConfig({ "t3code/no-manual-effect-runtime-in-tests": "error", "t3code/no-native-title-tooltip": "error", "t3code/no-test-in-loop": "error", + "t3code/no-unscoped-has": "error", "t3code/namespace-node-imports": "error", }, overrides: [ -- 2.54.0