Skip to content

Commit 77b3cb5

Browse files
authored
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: #18524
1 parent 274be93 commit 77b3cb5

File tree

6 files changed

+287
-37
lines changed

6 files changed

+287
-37
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888))
13+
- Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885))
1314

1415
## [4.1.13] - 2025-09-03
1516

packages/tailwindcss/src/compat/plugin-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export function buildPluginApi({
154154

155155
// CSS-in-JS object
156156
else if (typeof variant === 'object') {
157-
designSystem.variants.fromAst(name, objectToAst(variant))
157+
designSystem.variants.fromAst(name, objectToAst(variant), designSystem)
158158
}
159159
},
160160
matchVariant(name, fn, options) {

packages/tailwindcss/src/index.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4343,6 +4343,180 @@ describe('@custom-variant', () => {
43434343
}"
43444344
`)
43454345
})
4346+
4347+
test('@custom-variant can reuse existing @variant in the definition', async () => {
4348+
expect(
4349+
await compileCss(
4350+
css`
4351+
@custom-variant hocus {
4352+
@variant hover {
4353+
@variant focus {
4354+
@slot;
4355+
}
4356+
}
4357+
}
4358+
4359+
@tailwind utilities;
4360+
`,
4361+
['hocus:flex'],
4362+
),
4363+
).toMatchInlineSnapshot(`
4364+
"@media (hover: hover) {
4365+
.hocus\\:flex:hover:focus {
4366+
display: flex;
4367+
}
4368+
}"
4369+
`)
4370+
})
4371+
4372+
test('@custom-variant can reuse @custom-variant that is defined later', async () => {
4373+
expect(
4374+
await compileCss(
4375+
css`
4376+
@custom-variant hocus {
4377+
@variant custom-hover {
4378+
@variant focus {
4379+
@slot;
4380+
}
4381+
}
4382+
}
4383+
4384+
@custom-variant custom-hover (&:hover);
4385+
4386+
@tailwind utilities;
4387+
`,
4388+
['hocus:flex'],
4389+
),
4390+
).toMatchInlineSnapshot(`
4391+
".hocus\\:flex:hover:focus {
4392+
display: flex;
4393+
}"
4394+
`)
4395+
})
4396+
4397+
test('@custom-variant can reuse existing @variant that is overwritten later', async () => {
4398+
expect(
4399+
await compileCss(
4400+
css`
4401+
@custom-variant hocus {
4402+
@variant hover {
4403+
@variant focus {
4404+
@slot;
4405+
}
4406+
}
4407+
}
4408+
4409+
@custom-variant hover (&:hover);
4410+
4411+
@tailwind utilities;
4412+
`,
4413+
['hocus:flex'],
4414+
),
4415+
).toMatchInlineSnapshot(`
4416+
".hocus\\:flex:hover:focus {
4417+
display: flex;
4418+
}"
4419+
`)
4420+
})
4421+
4422+
test('@custom-variant cannot use @variant that eventually results in a circular dependency', async () => {
4423+
return expect(() =>
4424+
compileCss(
4425+
css`
4426+
@custom-variant custom-variant {
4427+
@variant foo {
4428+
@slot;
4429+
}
4430+
}
4431+
4432+
@custom-variant foo {
4433+
@variant hover {
4434+
@variant bar {
4435+
@slot;
4436+
}
4437+
}
4438+
}
4439+
4440+
@custom-variant bar {
4441+
@variant focus {
4442+
@variant baz {
4443+
@slot;
4444+
}
4445+
}
4446+
}
4447+
4448+
@custom-variant baz {
4449+
@variant active {
4450+
@variant foo {
4451+
@slot;
4452+
}
4453+
}
4454+
}
4455+
4456+
@tailwind utilities;
4457+
`,
4458+
['foo:flex'],
4459+
),
4460+
).rejects.toThrowErrorMatchingInlineSnapshot(`
4461+
[Error: Circular dependency detected in custom variants:
4462+
4463+
@custom-variant custom-variant {
4464+
@variant foo { … }
4465+
}
4466+
@custom-variant foo { /* ← */
4467+
@variant bar { … }
4468+
}
4469+
@custom-variant bar {
4470+
@variant baz { … }
4471+
}
4472+
@custom-variant baz {
4473+
@variant foo { … }
4474+
}
4475+
]
4476+
`)
4477+
})
4478+
4479+
test('@custom-variant setup that results in a circular dependency error can be solved', async () => {
4480+
expect(
4481+
await compileCss(
4482+
css`
4483+
@custom-variant foo {
4484+
@variant hover {
4485+
@variant bar {
4486+
@slot;
4487+
}
4488+
}
4489+
}
4490+
4491+
@custom-variant bar {
4492+
@variant focus {
4493+
@variant baz {
4494+
@slot;
4495+
}
4496+
}
4497+
}
4498+
4499+
@custom-variant baz {
4500+
@variant active {
4501+
@variant foo {
4502+
@slot;
4503+
}
4504+
}
4505+
}
4506+
4507+
/* Break the circle */
4508+
@custom-variant foo ([data-broken-circle] &);
4509+
4510+
@tailwind utilities;
4511+
`,
4512+
['baz:flex'],
4513+
),
4514+
).toMatchInlineSnapshot(`
4515+
"[data-broken-circle] .baz\\:flex:active {
4516+
display: flex;
4517+
}"
4518+
`)
4519+
})
43464520
})
43474521

43484522
describe('@utility', () => {

packages/tailwindcss/src/index.ts

Lines changed: 39 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { substituteAtImports } from './at-import'
2222
import { applyCompatibilityHooks } from './compat/apply-compat-hooks'
2323
import type { UserConfig } from './compat/config/types'
2424
import { type Plugin } from './compat/plugin-api'
25-
import { applyVariant, compileCandidates } from './compile'
25+
import { compileCandidates } from './compile'
2626
import { substituteFunctions } from './css-functions'
2727
import * as CSS from './css-parser'
2828
import { buildDesignSystem, type DesignSystem } from './design-system'
@@ -32,7 +32,8 @@ import { createCssUtility } from './utilities'
3232
import { expand } from './utils/brace-expansion'
3333
import { escape, unescape } from './utils/escape'
3434
import { segment } from './utils/segment'
35-
import { compoundsForSelectors, IS_VALID_VARIANT_NAME } from './variants'
35+
import { topologicalSort } from './utils/topological-sort'
36+
import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtVariant } from './variants'
3637
export type Config = UserConfig
3738

3839
const IS_VALID_PREFIX = /^[a-z]+$/
@@ -150,7 +151,8 @@ async function parseCss(
150151

151152
let important = null as boolean | null
152153
let theme = new Theme()
153-
let customVariants: ((designSystem: DesignSystem) => void)[] = []
154+
let customVariants = new Map<string, (designSystem: DesignSystem) => void>()
155+
let customVariantDependencies = new Map<string, Set<string>>()
154156
let customUtilities: ((designSystem: DesignSystem) => void)[] = []
155157
let firstThemeRule = null as StyleRule | null
156158
let utilitiesNode = null as AtRule | null
@@ -390,7 +392,7 @@ async function parseCss(
390392
}
391393
}
392394

393-
customVariants.push((designSystem) => {
395+
customVariants.set(name, (designSystem) => {
394396
designSystem.variants.static(
395397
name,
396398
(r) => {
@@ -411,6 +413,7 @@ async function parseCss(
411413
},
412414
)
413415
})
416+
customVariantDependencies.set(name, new Set<string>())
414417

415418
return
416419
}
@@ -431,9 +434,17 @@ async function parseCss(
431434
// }
432435
// ```
433436
else {
434-
customVariants.push((designSystem) => {
435-
designSystem.variants.fromAst(name, node.nodes)
437+
let dependencies = new Set<string>()
438+
walk(node.nodes, (child) => {
439+
if (child.kind === 'at-rule' && child.name === '@variant') {
440+
dependencies.add(child.params)
441+
}
442+
})
443+
444+
customVariants.set(name, (designSystem) => {
445+
designSystem.variants.fromAst(name, node.nodes, designSystem)
436446
})
447+
customVariantDependencies.set(name, dependencies)
437448

438449
return
439450
}
@@ -605,8 +616,27 @@ async function parseCss(
605616
sources,
606617
})
607618

608-
for (let customVariant of customVariants) {
609-
customVariant(designSystem)
619+
for (let name of customVariants.keys()) {
620+
// Pre-register the variant to ensure its position in the variant list is
621+
// based on the order we see them in the CSS.
622+
designSystem.variants.static(name, () => {})
623+
}
624+
625+
// Register custom variants in order
626+
for (let variant of topologicalSort(customVariantDependencies, {
627+
onCircularDependency(path, start) {
628+
let output = toCss(
629+
path.map((name, idx) => {
630+
return atRule('@custom-variant', name, [atRule('@variant', path[idx + 1] ?? start, [])])
631+
}),
632+
)
633+
.replaceAll(';', ' { … }')
634+
.replace(`@custom-variant ${start} {`, `@custom-variant ${start} { /* ← */`)
635+
636+
throw new Error(`Circular dependency detected in custom variants:\n\n${output}`)
637+
},
638+
})) {
639+
customVariants.get(variant)?.(designSystem)
610640
}
611641

612642
for (let customUtility of customUtilities) {
@@ -636,30 +666,7 @@ async function parseCss(
636666
firstThemeRule.nodes = [context({ theme: true }, nodes)]
637667
}
638668

639-
// Replace the `@variant` at-rules with the actual variant rules.
640-
if (variantNodes.length > 0) {
641-
for (let variantNode of variantNodes) {
642-
// Starting with the `&` rule node
643-
let node = styleRule('&', variantNode.nodes)
644-
645-
let variant = variantNode.params
646-
647-
let variantAst = designSystem.parseVariant(variant)
648-
if (variantAst === null) {
649-
throw new Error(`Cannot use \`@variant\` with unknown variant: ${variant}`)
650-
}
651-
652-
let result = applyVariant(node, variantAst, designSystem.variants)
653-
if (result === null) {
654-
throw new Error(`Cannot use \`@variant\` with variant: ${variant}`)
655-
}
656-
657-
// Update the variant at-rule node, to be the `&` rule node
658-
Object.assign(variantNode, node)
659-
}
660-
features |= Features.Variants
661-
}
662-
669+
features |= substituteAtVariant(ast, designSystem)
663670
features |= substituteFunctions(ast, designSystem)
664671
features |= substituteAtApply(ast, designSystem)
665672

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export function topologicalSort<Key>(
2+
graph: Map<Key, Set<Key>>,
3+
options: { onCircularDependency: (path: Key[], start: Key) => void },
4+
): Key[] {
5+
let seen = new Set<Key>()
6+
let wip = new Set<Key>()
7+
8+
let sorted: Key[] = []
9+
10+
function visit(node: Key, path: Key[] = []) {
11+
if (!graph.has(node)) return
12+
if (seen.has(node)) return
13+
14+
// Circular dependency detected
15+
if (wip.has(node)) options.onCircularDependency?.(path, node)
16+
17+
wip.add(node)
18+
19+
for (let dependency of graph.get(node) ?? []) {
20+
path.push(node)
21+
visit(dependency, path)
22+
path.pop()
23+
}
24+
25+
seen.add(node)
26+
wip.delete(node)
27+
28+
sorted.push(node)
29+
}
30+
31+
for (let node of graph.keys()) {
32+
visit(node)
33+
}
34+
35+
return sorted
36+
}

0 commit comments

Comments
 (0)