Handle @variant inside @custom-variant (#18885)

This PR fixes an issue where you cannot use `@variant` inside a
`@custom-variant`. While you can use `@variant` in normal CSS, you
cannot inside of `@custom-variant`. Today this silently fails and emits
invalid CSS.
```css
@custom-variant dark {
  @variant data-dark {
    @slot;
  }
}
```
```html
<div class="dark:flex"></div>
```

Would result in:
```css
.dark\:flex {
  @variant data-dark {
    display: flex;
  }
}
```

To solve it we have 3 potential solutions:

1. Consider it user error — but since it generates CSS and you don't
really get an error you could be shipping broken CSS unknowingly.
1. We could try and detect this and not generate CSS for this and
potentially show a warning.
1. We could make it work as expected — which is what this PR does.

Some important notes:

1. The evaluation of the `@custom-variant` only happens when you
actually need it. That means that `@variant` inside `@custom-variant`
will always have the implementation of the last definition of that
variant.

In other words, if you use `@variant hover` inside a `@custom-variant`,
and later you override the `hover` variant, the `@custom-variant` will
use the new implementation.
1. If you happen to introduce a circular dependency, then an error will
be thrown during the build step.

You can consider it a bug fix or a new feature it's a bit of a gray
area. But
one thing that is cool about this is that you can ship a plugin that
looks like
this:
```css
@custom-variant hocus {
  @variant hover {
    @slot;
  }

  @variant focus {
    @slot;
  }
}
```

And it will use the implementation of `hover` and `focus` that the user
has defined. So if they have a custom `hover` or `focus` variant it will
just work.

By default `hocus:underline` would generate:
```css
@media (hover: hover) {
  .hocus\:underline:hover {
    text-decoration-line: underline;
  }
}

.hocus\:underline:focus {
  text-decoration-line: underline;
}
```

But if you have a custom `hover` variant like:
```css
@custom-variant hover (&:hover);
```

Then `hocus:underline` would generate:
```css
.hocus\:underline:hover, .hocus\:underline:focus {
  text-decoration-line: underline;
}
```

### Test plan

1. Existing tests pass
2. Added tests with this new functionality handled
3. Made sure to add a test for circular dependencies + error message
4. Made sure that if you "fix" the circular dependency (by overriding a
variant) that everything is generated as expected.

Fixes: https://github.com/tailwindlabs/tailwindcss/issues/18524
This commit is contained in:
Robin Malfait 2025-09-05 14:24:11 +02:00 • committed by GitHub
parent 274be93fd8
commit 77b3cb5318
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 287 additions and 37 deletions

View file

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888))
- Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885))
## [4.1.13] - 2025-09-03

View file

@ -154,7 +154,7 @@ export function buildPluginApi({
// CSS-in-JS object
else if (typeof variant === 'object') {
designSystem.variants.fromAst(name, objectToAst(variant))
designSystem.variants.fromAst(name, objectToAst(variant), designSystem)
}
},
matchVariant(name, fn, options) {

View file

@ -4343,6 +4343,180 @@ describe('@custom-variant', () => {
}"
`)
})
test('@custom-variant can reuse existing @variant in the definition', async () => {
expect(
await compileCss(
css`
@custom-variant hocus {
@variant hover {
@variant focus {
@slot;
}
}
}
@tailwind utilities;
`,
['hocus:flex'],
),
).toMatchInlineSnapshot(`
"@media (hover: hover) {
.hocus\\:flex:hover:focus {
display: flex;
}
}"
`)
})
test('@custom-variant can reuse @custom-variant that is defined later', async () => {
expect(
await compileCss(
css`
@custom-variant hocus {
@variant custom-hover {
@variant focus {
@slot;
}
}
}
@custom-variant custom-hover (&:hover);
@tailwind utilities;
`,
['hocus:flex'],
),
).toMatchInlineSnapshot(`
".hocus\\:flex:hover:focus {
display: flex;
}"
`)
})
test('@custom-variant can reuse existing @variant that is overwritten later', async () => {
expect(
await compileCss(
css`
@custom-variant hocus {
@variant hover {
@variant focus {
@slot;
}
}
}
@custom-variant hover (&:hover);
@tailwind utilities;
`,
['hocus:flex'],
),
).toMatchInlineSnapshot(`
".hocus\\:flex:hover:focus {
display: flex;
}"
`)
})
test('@custom-variant cannot use @variant that eventually results in a circular dependency', async () => {
return expect(() =>
compileCss(
css`
@custom-variant custom-variant {
@variant foo {
@slot;
}
}
@custom-variant foo {
@variant hover {
@variant bar {
@slot;
}
}
}
@custom-variant bar {
@variant focus {
@variant baz {
@slot;
}
}
}
@custom-variant baz {
@variant active {
@variant foo {
@slot;
}
}
}
@tailwind utilities;
`,
['foo:flex'],
),
).rejects.toThrowErrorMatchingInlineSnapshot(`
[Error: Circular dependency detected in custom variants:
@custom-variant custom-variant {
@variant foo { … }
}
@custom-variant foo { /* ← */
@variant bar { … }
}
@custom-variant bar {
@variant baz { … }
}
@custom-variant baz {
@variant foo { … }
}
]
`)
})
test('@custom-variant setup that results in a circular dependency error can be solved', async () => {
expect(
await compileCss(
css`
@custom-variant foo {
@variant hover {
@variant bar {
@slot;
}
}
}
@custom-variant bar {
@variant focus {
@variant baz {
@slot;
}
}
}
@custom-variant baz {
@variant active {
@variant foo {
@slot;
}
}
}
/* Break the circle */
@custom-variant foo ([data-broken-circle] &);
@tailwind utilities;
`,
['baz:flex'],
),
).toMatchInlineSnapshot(`
"[data-broken-circle] .baz\\:flex:active {
display: flex;
}"
`)
})
})
describe('@utility', () => {

View file

@ -22,7 +22,7 @@ import { substituteAtImports } from './at-import'
import { applyCompatibilityHooks } from './compat/apply-compat-hooks'
import type { UserConfig } from './compat/config/types'
import { type Plugin } from './compat/plugin-api'
import { applyVariant, compileCandidates } from './compile'
import { compileCandidates } from './compile'
import { substituteFunctions } from './css-functions'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
@ -32,7 +32,8 @@ import { createCssUtility } from './utilities'
import { expand } from './utils/brace-expansion'
import { escape, unescape } from './utils/escape'
import { segment } from './utils/segment'
import { compoundsForSelectors, IS_VALID_VARIANT_NAME } from './variants'
import { topologicalSort } from './utils/topological-sort'
import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtVariant } from './variants'
export type Config = UserConfig
const IS_VALID_PREFIX = /^[a-z]+$/
@ -150,7 +151,8 @@ async function parseCss(
let important = null as boolean | null
let theme = new Theme()
let customVariants: ((designSystem: DesignSystem) => void)[] = []
let customVariants = new Map<string, (designSystem: DesignSystem) => void>()
let customVariantDependencies = new Map<string, Set<string>>()
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
let firstThemeRule = null as StyleRule | null
let utilitiesNode = null as AtRule | null
@ -390,7 +392,7 @@ async function parseCss(
}
}
customVariants.push((designSystem) => {
customVariants.set(name, (designSystem) => {
designSystem.variants.static(
name,
(r) => {
@ -411,6 +413,7 @@ async function parseCss(
},
)
})
customVariantDependencies.set(name, new Set<string>())
return
}
@ -431,10 +434,18 @@ async function parseCss(
// }
// ```
else {
customVariants.push((designSystem) => {
designSystem.variants.fromAst(name, node.nodes)
let dependencies = new Set<string>()
walk(node.nodes, (child) => {
if (child.kind === 'at-rule' && child.name === '@variant') {
dependencies.add(child.params)
}
})
customVariants.set(name, (designSystem) => {
designSystem.variants.fromAst(name, node.nodes, designSystem)
})
customVariantDependencies.set(name, dependencies)
return
}
}
@ -605,8 +616,27 @@ async function parseCss(
sources,
})
for (let customVariant of customVariants) {
customVariant(designSystem)
for (let name of customVariants.keys()) {
// Pre-register the variant to ensure its position in the variant list is
// based on the order we see them in the CSS.
designSystem.variants.static(name, () => {})
}
// Register custom variants in order
for (let variant of topologicalSort(customVariantDependencies, {
onCircularDependency(path, start) {
let output = toCss(
path.map((name, idx) => {
return atRule('@custom-variant', name, [atRule('@variant', path[idx + 1] ?? start, [])])
}),
)
.replaceAll(';', ' { … }')
.replace(`@custom-variant ${start} {`, `@custom-variant ${start} { /* ← */`)
throw new Error(`Circular dependency detected in custom variants:\n\n${output}`)
},
})) {
customVariants.get(variant)?.(designSystem)
}
for (let customUtility of customUtilities) {
@ -636,30 +666,7 @@ async function parseCss(
firstThemeRule.nodes = [context({ theme: true }, nodes)]
}
// Replace the `@variant` at-rules with the actual variant rules.
if (variantNodes.length > 0) {
for (let variantNode of variantNodes) {
// Starting with the `&` rule node
let node = styleRule('&', variantNode.nodes)
let variant = variantNode.params
let variantAst = designSystem.parseVariant(variant)
if (variantAst === null) {
throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`)
}
let result = applyVariant(node, variantAst, designSystem.variants)
if (result === null) {
throw new Error(`Cannot use \`@variant\` with variant: ${variant}`)
}
// Update the variant at-rule node, to be the `&` rule node
Object.assign(variantNode, node)
}
features |= Features.Variants
}
features |= substituteAtVariant(ast, designSystem)
features |= substituteFunctions(ast, designSystem)
features |= substituteAtApply(ast, designSystem)

View file

@ -0,0 +1,36 @@
export function topologicalSort<Key>(
graph: Map<Key, Set<Key>>,
options: { onCircularDependency: (path: Key[], start: Key) => void },
): Key[] {
let seen = new Set<Key>()
let wip = new Set<Key>()
let sorted: Key[] = []
function visit(node: Key, path: Key[] = []) {
if (!graph.has(node)) return
if (seen.has(node)) return
// Circular dependency detected
if (wip.has(node)) options.onCircularDependency?.(path, node)
wip.add(node)
for (let dependency of graph.get(node) ?? []) {
path.push(node)
visit(dependency, path)
path.pop()
}
seen.add(node)
wip.delete(node)
sorted.push(node)
}
for (let node of graph.keys()) {
visit(node)
}
return sorted
}

View file

@ -1,3 +1,4 @@
import { Features } from '.'
import {
WalkAction,
atRoot,
@ -12,6 +13,8 @@ import {
type StyleRule,
} from './ast'
import { type Variant } from './candidate'
import { applyVariant } from './compile'
import type { DesignSystem } from './design-system'
import type { Theme } from './theme'
import { compareBreakpoints } from './utils/compare-breakpoints'
import { DefaultMap } from './utils/default-map'
@ -80,12 +83,15 @@ export class Variants {
})
}
fromAst(name: string, ast: AstNode[]) {
fromAst(name: string, ast: AstNode[], designSystem: DesignSystem) {
let selectors: string[] = []
let usesAtVariant = false
walk(ast, (node) => {
if (node.kind === 'rule') {
selectors.push(node.selector)
} else if (node.kind === 'at-rule' && node.name === '@variant') {
usesAtVariant = true
} else if (node.kind === 'at-rule' && node.name !== '@slot') {
selectors.push(`${node.name} ${node.params}`)
}
@ -95,12 +101,11 @@ export class Variants {
name,
(r) => {
let body = structuredClone(ast)
if (usesAtVariant) substituteAtVariant(body, designSystem)
substituteAtSlot(body, r.nodes)
r.nodes = body
},
{
compounds: compoundsForSelectors(selectors),
},
{ compounds: compoundsForSelectors(selectors) },
)
}
@ -1198,3 +1203,30 @@ export function substituteAtSlot(ast: AstNode[], nodes: AstNode[]) {
}
})
}
export function substituteAtVariant(ast: AstNode[], designSystem: DesignSystem): Features {
let features = Features.None
walk(ast, (variantNode, { replaceWith }) => {
if (variantNode.kind !== 'at-rule' || variantNode.name !== '@variant') return
// Starting with the `&` rule node
let node = styleRule('&', variantNode.nodes)
let variant = variantNode.params
let variantAst = designSystem.parseVariant(variant)
if (variantAst === null) {
throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`)
}
let result = applyVariant(node, variantAst, designSystem.variants)
if (result === null) {
throw new Error(`Cannot use \`@variant\` with variant: ${variant}`)
}
// Update the variant at-rule node, to be the `&` rule node
replaceWith(node)
features |= Features.Variants
})
return features
}