Properly negate custom variants with @container when used with not-* (#20059)

This PR fixes an issue where a custom variant declared with a
`@container` wasn't properly negated when using it in combination with
the `not-*` variant.

Given this CSS:
```css
@custom-variant has-a {
  @container style(--a) {
    @slot;
  }
}
```

If you then used `not-has-a:flex`, then the following CSS was produced:
```css
.not-has-a\:flex {
  @container style(--a) not {
    display: flex;
  }
}
```
But we expect the `not` to be in the correct location:
```css
.not-has-a\:flex {
  @container not style(--a) {
    display: flex;
  }
}
```

The issue was that we did some string related checks, and we assumed
that the `query` part of the `@container` (`style(--a)`) had to start
with a `(` character.

To fix this, we now parse the value to an AST, and verify the AST shape
before manipulating it. This now checks whether the `query` part is a
function (both `(…)` and `style(…)` are considered functions).

Also added some additional tests that were already handled, these cases
look like:

- `@container {query}`
- `@container not {query}`
- `@container {name} not {query}`
- `@container {name} {query}`


Fixes: #20058

## Test plan

1. Added a failing test for the use case of the linked issue.
2. Added a few more additional tests to explicitly track some use cases
we handled already but didn't track via tests.
3. All other tests still pass as expected.
This commit is contained in:
Robin Malfait 2026-05-15 00:08:10 +02:00 • committed by GitHub
parent 460a008120
commit 36417cbd12
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 136 additions and 23 deletions

View file

@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028))
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059))
## [4.3.0] - 2026-05-08

View file

@ -1770,6 +1770,69 @@ test('not', async () => {
"
`)
// https://github.com/tailwindlabs/tailwindcss/issues/20058
expect(
await run(
['not-has-a:flex', 'not-has-b:flex', 'not-has-c:flex', 'not-has-d:flex'],
css`
@custom-variant has-a {
@container style(--a) {
@slot;
}
}
/* Already negated case */
@custom-variant has-b {
@container not style(--b) {
@slot;
}
}
/* Named @container */
@custom-variant has-c {
@container foo style(--c) {
@slot;
}
}
/* Named @container, that's already negated case */
@custom-variant has-d {
@container bar not style(--d) {
@slot;
}
}
@tailwind utilities;
`,
),
).toMatchInlineSnapshot(`
"
@container not style(--a) {
.not-has-a\\:flex {
display: flex;
}
}
@container style(--b) {
.not-has-b\\:flex {
display: flex;
}
}
@container foo not style(--c) {
.not-has-c\\:flex {
display: flex;
}
}
@container bar style(--d) {
.not-has-d\\:flex {
display: flex;
}
}
"
`)
expect(
await run(
[

View file

@ -19,6 +19,7 @@ import { compareBreakpoints } from './utils/compare-breakpoints'
import { DefaultMap } from './utils/default-map'
import { isPositiveInteger } from './utils/infer-data-type'
import { segment } from './utils/segment'
import * as ValueParser from './value-parser'
import { walk, WalkAction } from './walk'
export const IS_VALID_VARIANT_NAME = /^@?[a-z0-9][a-zA-Z0-9_-]*(?<![_-])$/
@ -375,35 +376,83 @@ export function createVariants(theme: Theme): Variants {
function negateConditions(ruleName: string, conditions: string[]) {
return conditions.map((condition) => {
condition = condition.trim()
switch (ruleName) {
case '@container': {
let ast = ValueParser.parse(condition.trim())
let parts = segment(condition, ' ')
// @container {query}
// ^^^^^^^
// ast 0
if (ast.length >= 1 && ast[0].kind === 'function') {
return `not ${condition}`
}
// @media not {query}
// @supports not {query}
// @container not {query}
if (parts[0] === 'not') {
return parts.slice(1).join(' ')
}
// @container not {query}
// ^^^ ^ ^^^^^^^
// ast 0 1 2
else if (
ast.length >= 3 &&
ast[0].kind === 'word' &&
ast[0].value === 'not' &&
ast[2].kind === 'function'
) {
// Drop the leading `not` (ast[0]) and separator (ast[1])
ast.splice(0, 2)
return ValueParser.toCss(ast)
}
// @container {name} not {query}
// ^^^^^^ ^ ^^^ ^ ^^^^^^^
// ast 0 1 2 3 4
else if (
ast.length >= 5 &&
ast[0].kind === 'word' &&
ast[2].kind === 'word' &&
ast[2].value === 'not' &&
ast[4].kind === 'function'
) {
// Drop the `not` (ast[2]) and separator (ast[3])
ast.splice(2, 2)
return ValueParser.toCss(ast)
}
// @container {name} {query}
// ^^^^^^ ^ ^^^^^^^
// ast 0 1 2
else if (
ast.length >= 3 &&
ast[0].kind === 'word' &&
ast[0].value !== 'not' &&
ast[2].kind === 'function'
) {
// Inject a separator and a `not`, after the `name` (ast[0])
ast.splice(1, 0, { kind: 'separator', value: ' ' }, { kind: 'word', value: 'not' })
return ValueParser.toCss(ast)
}
// Fallback
else {
return `not ${condition}`
}
}
default: {
condition = condition.trim()
let parts = segment(condition, ' ')
// @media not {query}
// @supports not {query}
if (parts[0] === 'not') {
return parts.slice(1).join(' ')
}
if (ruleName === '@container') {
// @container {query}
if (parts[0][0] === '(') {
return `not ${condition}`
}
// @container {name} not {query}
else if (parts[1] === 'not') {
return `${parts[0]} ${parts.slice(2).join(' ')}`
}
// @container {name} {query}
else {
return `${parts[0]} not ${parts.slice(1).join(' ')}`
}
}
return `not ${condition}`
})
}