Don't wrap relative selectors in arbitrary variants with :is(…) (#14203)

Prior to this PR, we weren't accounting for the fact that `:has(…)`
supports [relative
selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors/Selector_structure#relative_selector),
which are sort of like partial selectors that can start with a
combinator like `>`, `+`, or `~`.

Before, a class like `has-[>img]:flex` would generate this:

```css
.has-\[\>img\]\:flex:has(*:is(> img)) {
  display: flex;
}
```

This was incorrect because `*:is(> img)` isn't even valid CSS at all, so
the rule would do nothing.

After this change, we generate this instead:

```css
.has-\[\>img\]\:flex:has(> img) {
  display: flex;
}
```

This PR also ensures that relative selectors are recognized as invalid
in places where they are not supported, so classes like
`group-[>img]:flex` for example will produce nothing now instead of
invalid CSS.

This is mostly a simple change but it did involve storing some
additional information in the variant AST.

Fixes #14202.

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
Co-authored-by: Robin Malfait <malfait.robin@gmail.com>
This commit is contained in:
Adam Wathan 2024-08-16 13:42:04 -04:00 • committed by GitHub
parent e10b786437
commit 3df38a7458
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 147 additions and 13 deletions

View file

@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for `addBase` plugins using the `@plugin` directive ([#14172](https://github.com/tailwindlabs/tailwindcss/pull/14172))
- Add support for the `tailwindcss/plugin` export ([#14173](https://github.com/tailwindlabs/tailwindcss/pull/14173))
### Fixed
- Don't wrap relative selectors in arbitrary variants with `:is(…)` ([#14203](https://github.com/tailwindlabs/tailwindcss/pull/14203))
## [4.0.0-alpha.19] - 2024-08-09
### Added

View file

@ -146,6 +146,7 @@ it('should parse a simple utility with an arbitrary variant', () => {
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "& p",
},
],
@ -207,6 +208,7 @@ it('should parse compound variants with an arbitrary value as an arbitrary varia
"variant": {
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "& p",
},
},
@ -316,6 +318,7 @@ it('should parse a simple utility with an arbitrary media variant', () => {
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "@media(width>=123px)",
},
],
@ -1102,11 +1105,13 @@ it('should parse arbitrary properties that are important and using stacked arbit
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "& p",
},
{
"compounds": true,
"kind": "arbitrary",
"relative": false,
"selector": "@media(width>=123px)",
},
],

View file

@ -101,6 +101,10 @@ export type Variant =
// If true, it can be applied as a child of a compound variant
compounds: boolean
// Whether or not the selector is a relative selector
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors/Selector_structure#relative_selector
relative: boolean
}
/**
@ -510,21 +514,23 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia
let selector = decodeArbitraryValue(variant.slice(1, -1))
if (selector[0] !== '@') {
// Ensure `&` is always present by wrapping the selector in `&:is(…)`
//
// E.g.:
//
// - `[p]:flex`
if (!selector.includes('&')) {
selector = `&:is(${selector})`
}
let relative = selector[0] === '>' || selector[0] === '+' || selector[0] === '~'
// Ensure `&` is always present by wrapping the selector in `&:is(…)`,
// unless it's a relative selector like `> img`.
//
// E.g.:
//
// - `[p]:flex`
if (!relative && selector[0] !== '@' && !selector.includes('&')) {
selector = `&:is(${selector})`
}
return {
kind: 'arbitrary',
selector,
compounds: true,
relative,
}
}

View file

@ -134,8 +134,19 @@ export function compileAstNodes(rawCandidate: string, designSystem: DesignSystem
}
}
export function applyVariant(node: Rule, variant: Variant, variants: Variants): null | void {
export function applyVariant(
node: Rule,
variant: Variant,
variants: Variants,
depth: number = 0,
): null | void {
if (variant.kind === 'arbitrary') {
// Relative selectors are not valid as an entire arbitrary variant, only as
// an arbitrary variant that is part of another compound variant.
//
// E.g. `[>img]:flex` is not valid, but `has-[>img]:flex` is
if (variant.relative && depth === 0) return null
node.nodes = [rule(variant.selector, node.nodes)]
return
}
@ -162,7 +173,7 @@ export function applyVariant(node: Rule, variant: Variant, variants: Variants):
// affecting the original node.
let isolatedNode = rule('@slot', [])
let result = applyVariant(isolatedNode, variant.variant, variants)
let result = applyVariant(isolatedNode, variant.variant, variants, depth + 1)
if (result === null) return null
for (let child of isolatedNode.nodes) {

View file

@ -380,6 +380,10 @@ describe('arbitrary variants', () => {
}"
`)
})
it('discards arbitrary variants using relative selectors', async () => {
expect(await run(['[>img]:flex', '[+img]:flex', '[~img]:flex'])).toBe('')
})
})
describe('variant stacking', () => {

View file

@ -759,7 +759,7 @@ test('group-[...]', async () => {
css`
@tailwind utilities;
`,
['group-[@media_foo]:flex'],
['group-[@media_foo]:flex', 'group-[>img]:flex'],
),
).toEqual('')
})
@ -861,7 +861,7 @@ test('peer-[...]', async () => {
css`
@tailwind utilities;
`,
['peer-[@media_foo]:flex'],
['peer-[@media_foo]:flex', 'peer-[>img]:flex'],
),
).toEqual('')
})
@ -1668,6 +1668,9 @@ test('not', async () => {
@tailwind utilities;
`,
[
'not-[>img]:flex',
'not-[+img]:flex',
'not-[~img]:flex',
'not-[:checked]/foo:flex',
'not-[@media_print]:flex',
'not-custom-at-rule:flex',
@ -1690,18 +1693,37 @@ test('has', async () => {
@tailwind utilities;
`,
[
'has-checked:flex',
'has-[:checked]:flex',
'has-[>img]:flex',
'has-[+img]:flex',
'has-[~img]:flex',
'has-[&>img]:flex',
'has-hocus:flex',
'group-has-[:checked]:flex',
'group-has-[:checked]/parent-name:flex',
'group-has-checked:flex',
'group-has-checked/parent-name:flex',
'group-has-[>img]:flex',
'group-has-[>img]/parent-name:flex',
'group-has-[+img]:flex',
'group-has-[~img]:flex',
'group-has-[&>img]:flex',
'group-has-[&>img]/parent-name:flex',
'group-has-hocus:flex',
'group-has-hocus/parent-name:flex',
'peer-has-[:checked]:flex',
'peer-has-[:checked]/sibling-name:flex',
'peer-has-checked:flex',
'peer-has-checked/sibling-name:flex',
'peer-has-[>img]:flex',
'peer-has-[>img]/sibling-name:flex',
'peer-has-[+img]:flex',
'peer-has-[~img]:flex',
'peer-has-[&>img]:flex',
'peer-has-[&>img]/sibling-name:flex',
'peer-has-hocus:flex',
'peer-has-hocus/sibling-name:flex',
],
@ -1711,6 +1733,10 @@ test('has', async () => {
display: flex;
}
.group-has-checked\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(:checked) *) {
display: flex;
}
.group-has-hocus\\:flex:is(:where(.group):has(:hover, :focus) *) {
display: flex;
}
@ -1727,10 +1753,38 @@ test('has', async () => {
display: flex;
}
.group-has-\\[\\&\\>img\\]\\:flex:is(:where(.group):has(* > img) *) {
display: flex;
}
.group-has-\\[\\&\\>img\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has(* > img) *) {
display: flex;
}
.group-has-\\[\\+img\\]\\:flex:is(:where(.group):has( + img) *) {
display: flex;
}
.group-has-\\[\\>img\\]\\:flex:is(:where(.group):has( > img) *) {
display: flex;
}
.group-has-\\[\\>img\\]\\/parent-name\\:flex:is(:where(.group\\/parent-name):has( > img) *) {
display: flex;
}
.group-has-\\[\\~img\\]\\:flex:is(:where(.group):has( ~ img) *) {
display: flex;
}
.peer-has-checked\\:flex:is(:where(.peer):has(:checked) ~ *) {
display: flex;
}
.peer-has-checked\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(:checked) ~ *) {
display: flex;
}
.peer-has-hocus\\:flex:is(:where(.peer):has(:hover, :focus) ~ *) {
display: flex;
}
@ -1747,12 +1801,56 @@ test('has', async () => {
display: flex;
}
.peer-has-\\[\\&\\>img\\]\\:flex:is(:where(.peer):has(* > img) ~ *) {
display: flex;
}
.peer-has-\\[\\&\\>img\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has(* > img) ~ *) {
display: flex;
}
.peer-has-\\[\\+img\\]\\:flex:is(:where(.peer):has( + img) ~ *) {
display: flex;
}
.peer-has-\\[\\>img\\]\\:flex:is(:where(.peer):has( > img) ~ *) {
display: flex;
}
.peer-has-\\[\\>img\\]\\/sibling-name\\:flex:is(:where(.peer\\/sibling-name):has( > img) ~ *) {
display: flex;
}
.peer-has-\\[\\~img\\]\\:flex:is(:where(.peer):has( ~ img) ~ *) {
display: flex;
}
.has-checked\\:flex:has(:checked) {
display: flex;
}
.has-hocus\\:flex:has(:hover, :focus) {
display: flex;
}
.has-\\[\\:checked\\]\\:flex:has(:checked) {
display: flex;
}
.has-\\[\\&\\>img\\]\\:flex:has(* > img) {
display: flex;
}
.has-\\[\\+img\\]\\:flex:has( + img) {
display: flex;
}
.has-\\[\\>img\\]\\:flex:has( > img) {
display: flex;
}
.has-\\[\\~img\\]\\:flex:has( ~ img) {
display: flex;
}"
`)

View file

@ -204,6 +204,8 @@ export function createVariants(theme: Theme): Variants {
staticVariant('*', [':where(& > *)'], { compounds: false })
variants.compound('not', (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
if (variant.modifier) return null
let didApply = false
@ -242,6 +244,8 @@ export function createVariants(theme: Theme): Variants {
})
variants.compound('group', (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
// Name the group by appending the modifier to `group` class itself if
// present.
let groupSelector = variant.modifier
@ -301,6 +305,8 @@ export function createVariants(theme: Theme): Variants {
})
variants.compound('peer', (ruleNode, variant) => {
if (variant.variant.kind === 'arbitrary' && variant.variant.relative) return null
// Name the peer by appending the modifier to `peer` class itself if
// present.
let peerSelector = variant.modifier