Skip to content

Commit 36417cb

Browse files
authored
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.
1 parent 460a008 commit 36417cb

3 files changed

Lines changed: 134 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028))
1313
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
1414
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
15+
- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059))
1516

1617
## [4.3.0] - 2026-05-08
1718

packages/tailwindcss/src/variants.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1770,6 +1770,69 @@ test('not', async () => {
17701770
"
17711771
`)
17721772

1773+
// https://github.com/tailwindlabs/tailwindcss/issues/20058
1774+
expect(
1775+
await run(
1776+
['not-has-a:flex', 'not-has-b:flex', 'not-has-c:flex', 'not-has-d:flex'],
1777+
css`
1778+
@custom-variant has-a {
1779+
@container style(--a) {
1780+
@slot;
1781+
}
1782+
}
1783+
1784+
/* Already negated case */
1785+
@custom-variant has-b {
1786+
@container not style(--b) {
1787+
@slot;
1788+
}
1789+
}
1790+
1791+
/* Named @container */
1792+
@custom-variant has-c {
1793+
@container foo style(--c) {
1794+
@slot;
1795+
}
1796+
}
1797+
1798+
/* Named @container, that's already negated case */
1799+
@custom-variant has-d {
1800+
@container bar not style(--d) {
1801+
@slot;
1802+
}
1803+
}
1804+
1805+
@tailwind utilities;
1806+
`,
1807+
),
1808+
).toMatchInlineSnapshot(`
1809+
"
1810+
@container not style(--a) {
1811+
.not-has-a\\:flex {
1812+
display: flex;
1813+
}
1814+
}
1815+
1816+
@container style(--b) {
1817+
.not-has-b\\:flex {
1818+
display: flex;
1819+
}
1820+
}
1821+
1822+
@container foo not style(--c) {
1823+
.not-has-c\\:flex {
1824+
display: flex;
1825+
}
1826+
}
1827+
1828+
@container bar style(--d) {
1829+
.not-has-d\\:flex {
1830+
display: flex;
1831+
}
1832+
}
1833+
"
1834+
`)
1835+
17731836
expect(
17741837
await run(
17751838
[

packages/tailwindcss/src/variants.ts

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { compareBreakpoints } from './utils/compare-breakpoints'
1919
import { DefaultMap } from './utils/default-map'
2020
import { isPositiveInteger } from './utils/infer-data-type'
2121
import { segment } from './utils/segment'
22+
import * as ValueParser from './value-parser'
2223
import { walk, WalkAction } from './walk'
2324

2425
export const IS_VALID_VARIANT_NAME = /^@?[a-z0-9][a-zA-Z0-9_-]*(?<![_-])$/
@@ -375,35 +376,83 @@ export function createVariants(theme: Theme): Variants {
375376

376377
function negateConditions(ruleName: string, conditions: string[]) {
377378
return conditions.map((condition) => {
378-
condition = condition.trim()
379+
switch (ruleName) {
380+
case '@container': {
381+
let ast = ValueParser.parse(condition.trim())
382+
383+
// @container {query}
384+
// ^^^^^^^
385+
// ast 0
386+
if (ast.length >= 1 && ast[0].kind === 'function') {
387+
return `not ${condition}`
388+
}
379389

380-
let parts = segment(condition, ' ')
390+
// @container not {query}
391+
// ^^^ ^ ^^^^^^^
392+
// ast 0 1 2
393+
else if (
394+
ast.length >= 3 &&
395+
ast[0].kind === 'word' &&
396+
ast[0].value === 'not' &&
397+
ast[2].kind === 'function'
398+
) {
399+
// Drop the leading `not` (ast[0]) and separator (ast[1])
400+
ast.splice(0, 2)
401+
402+
return ValueParser.toCss(ast)
403+
}
381404

382-
// @media not {query}
383-
// @supports not {query}
384-
// @container not {query}
385-
if (parts[0] === 'not') {
386-
return parts.slice(1).join(' ')
387-
}
405+
// @container {name} not {query}
406+
// ^^^^^^ ^ ^^^ ^ ^^^^^^^
407+
// ast 0 1 2 3 4
408+
else if (
409+
ast.length >= 5 &&
410+
ast[0].kind === 'word' &&
411+
ast[2].kind === 'word' &&
412+
ast[2].value === 'not' &&
413+
ast[4].kind === 'function'
414+
) {
415+
// Drop the `not` (ast[2]) and separator (ast[3])
416+
ast.splice(2, 2)
417+
418+
return ValueParser.toCss(ast)
419+
}
388420

389-
if (ruleName === '@container') {
390-
// @container {query}
391-
if (parts[0][0] === '(') {
392-
return `not ${condition}`
393-
}
421+
// @container {name} {query}
422+
// ^^^^^^ ^ ^^^^^^^
423+
// ast 0 1 2
424+
else if (
425+
ast.length >= 3 &&
426+
ast[0].kind === 'word' &&
427+
ast[0].value !== 'not' &&
428+
ast[2].kind === 'function'
429+
) {
430+
// Inject a separator and a `not`, after the `name` (ast[0])
431+
ast.splice(1, 0, { kind: 'separator', value: ' ' }, { kind: 'word', value: 'not' })
432+
433+
return ValueParser.toCss(ast)
434+
}
394435

395-
// @container {name} not {query}
396-
else if (parts[1] === 'not') {
397-
return `${parts[0]} ${parts.slice(2).join(' ')}`
436+
// Fallback
437+
else {
438+
return `not ${condition}`
439+
}
398440
}
399441

400-
// @container {name} {query}
401-
else {
402-
return `${parts[0]} not ${parts.slice(1).join(' ')}`
442+
default: {
443+
condition = condition.trim()
444+
445+
let parts = segment(condition, ' ')
446+
447+
// @media not {query}
448+
// @supports not {query}
449+
if (parts[0] === 'not') {
450+
return parts.slice(1).join(' ')
451+
}
452+
453+
return `not ${condition}`
403454
}
404455
}
405-
406-
return `not ${condition}`
407456
})
408457
}
409458

0 commit comments

Comments
 (0)