From 73f3a6a74336b5d74ff69cae5465714650f1d2e3 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 29 Apr 2026 14:20:11 +0200 Subject: [PATCH] =?UTF-8?q?Canonicalization:=20migrate=20`[&:has(=E2=80=A6?= =?UTF-8?q?)]`=20to=20`has-[=E2=80=A6]`=20variant=20(#19991)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR improves the canonicalization around arbitrary variants containing `[&:has(…)]`, by converting them to `has-[…]`. Essentially when you have a variant, with `&:has(…)`, we will convert it: ```diff - [&:has([role=checkbox])]:flex + has-[[role=checkbox]]:flex ``` This also means that if the arbitrary selector inside of the `&:has(…)` can be converted to something that aligns with known variants, then we will do that as well: ```diff // `data-*` can be hoisted outside of the arbitrary value - [&:has([data-slot=description])]:flex + has-data-[slot=description]:flex // `aria-visible="true"` can be entirely replaced by `aria-visible` - [&:has([aria-visible="true"])]:flex + has-aria-visible:flex ``` Noticed this while looking into: https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1562 ## Test plan 1. Added additional tests for this use case 2. Existing tests still pass --- CHANGELOG.md | 1 + .../src/canonicalize-candidates.test.ts | 5 +++++ .../src/canonicalize-candidates.ts | 22 +++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e053c3e..bf22c654d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Canonicalization: preserve significant `_` whitespace in arbitrary values ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986)) - Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986)) - Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. `-mt-[20in]` → `mt-[-20in]`, not `mt-[-1920px]`) ([#19988](https://github.com/tailwindlabs/tailwindcss/pull/19988)) +- Canonicalization: migrate arbitrary `:has()` variants from `[&:has(…)]` to `has-[…]` ([#19991](https://github.com/tailwindlabs/tailwindcss/pull/19991)) ## [4.2.4] - 2026-04-21 diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 5e9bc166c..184414da3 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -1022,6 +1022,11 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['has-[[aria-visible]]:flex', 'has-aria-[visible]:flex'], ['has-[&:not(:nth-child(even))]:flex', 'has-odd:flex'], + + // Arbitrary variant to compound + arbitrary variants + ['[&:has([role=checkbox])]:flex', 'has-[[role=checkbox]]:flex'], + ['[&:has([aria-visible="true"])]:flex', 'has-aria-visible:flex'], + ['[&:has([data-slot=description])]:flex', 'has-data-[slot=description]:flex'], ])(testName, { timeout }, async (candidate, expected) => { let input = css` @import 'tailwindcss'; diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index 7cfdbf84c..118f51e60 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -1796,6 +1796,28 @@ function modernizeArbitraryValuesVariant( continue } + // `[&:has(…)]` can be replaced with `has-[…]` + if ( + // Only top-level, so `group-[&:has(…)]` is not covered + + parent === null && + // [&:has(…)]:flex + // ^ ^^^^^^ + ast.length === 2 && + ast[0].kind === 'selector' && + ast[0].value === '&' && + ast[1].kind === 'function' && + ast[1].value === ':has' && + ast[1].nodes.length === 1 && + ast[1].nodes[0].kind === 'selector' + ) { + replaceObject( + variant, + designSystem.parseVariant(`has-[${SelectorParser.toCss(ast[1].nodes)}]`), + ) + continue + } + // `in-*` variant. If the selector ends with ` &`, we can convert it to an // `in-*` variant. //