From f628ea8d9d57cbf252f543a09f7b66b8affb7159 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Wed, 3 Sep 2025 23:07:31 +0200 Subject: [PATCH 1/7] add failing test --- packages/tailwindcss/src/index.test.ts | 174 +++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index edddb55f0201..cfd0d1db8909 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -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', () => { From 0dc5ab577e1cab69f82fa2a5513b9bcb6c385d54 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 4 Sep 2025 19:11:00 +0200 Subject: [PATCH 2/7] add topological sort utility --- .../tailwindcss/src/utils/topological-sort.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/tailwindcss/src/utils/topological-sort.ts diff --git a/packages/tailwindcss/src/utils/topological-sort.ts b/packages/tailwindcss/src/utils/topological-sort.ts new file mode 100644 index 000000000000..ae20da4aac09 --- /dev/null +++ b/packages/tailwindcss/src/utils/topological-sort.ts @@ -0,0 +1,36 @@ +export function topologicalSort( + graph: Map>, + options: { onCircularDependency: (path: Key[], start: Key) => void }, +): Key[] { + let seen = new Set() + let wip = new Set() + + 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 +} From 19949cc413027de3f5204d68f9e03245f7a3bb1d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 4 Sep 2025 23:04:37 +0200 Subject: [PATCH 3/7] add `substituteAtVariant` utility --- packages/tailwindcss/src/variants.ts | 30 ++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 7fba81d20100..8902c56b6510 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -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' @@ -1198,3 +1201,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 +} From e3b4c4cf3b2275f2c4540b6778d6baeacb861a98 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 4 Sep 2025 23:06:54 +0200 Subject: [PATCH 4/7] handle `@variant` inside `@custom-variant`'s --- packages/tailwindcss/src/compat/plugin-api.ts | 2 +- packages/tailwindcss/src/variants.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 6d13f4678e95..3b2f0712c2af 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -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) { diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 8902c56b6510..4e5c46894913 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -83,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}`) } @@ -98,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) }, ) } From 5f5253004a9699a5a4798634f3afb06a68ddd12d Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 4 Sep 2025 23:07:50 +0200 Subject: [PATCH 5/7] use `substituteAtVariant` in main CSS handling --- packages/tailwindcss/src/index.ts | 29 +++-------------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 4be573efbb11..2ea9cd48e522 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -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,7 @@ 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 { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtVariant } from './variants' export type Config = UserConfig const IS_VALID_PREFIX = /^[a-z]+$/ @@ -636,30 +636,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) From 8ef8419611f00a6e2ac1652b8ea3775820bb6a17 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 4 Sep 2025 23:11:38 +0200 Subject: [PATCH 6/7] register `@custom-variant` in the correct topological order --- packages/tailwindcss/src/index.ts | 42 ++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 2ea9cd48e522..ca2a0b0df16b 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -32,6 +32,7 @@ import { createCssUtility } from './utilities' import { expand } from './utils/brace-expansion' import { escape, unescape } from './utils/escape' import { segment } from './utils/segment' +import { topologicalSort } from './utils/topological-sort' import { compoundsForSelectors, IS_VALID_VARIANT_NAME, substituteAtVariant } from './variants' export type Config = UserConfig @@ -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 void>() + let customVariantDependencies = new Map>() 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()) return } @@ -431,9 +434,17 @@ async function parseCss( // } // ``` else { - customVariants.push((designSystem) => { - designSystem.variants.fromAst(name, node.nodes) + let dependencies = new Set() + 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) { From 6636897d0b0bb7dbb468f30bcbee08e1314ffb12 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Sep 2025 00:01:23 +0200 Subject: [PATCH 7/7] update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee41d2d10f1..de2f0e7ba098 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885)) ## [4.1.13] - 2025-09-03