From 77b3cb5318840925d8a75a11cc90552a93507ddc Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 5 Sep 2025 14:24:11 +0200 Subject: [PATCH] Handle `@variant` inside `@custom-variant` (#18885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
``` 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 --- CHANGELOG.md | 1 + packages/tailwindcss/src/compat/plugin-api.ts | 2 +- packages/tailwindcss/src/index.test.ts | 174 ++++++++++++++++++ packages/tailwindcss/src/index.ts | 71 +++---- .../tailwindcss/src/utils/topological-sort.ts | 36 ++++ packages/tailwindcss/src/variants.ts | 40 +++- 6 files changed, 287 insertions(+), 37 deletions(-) create mode 100644 packages/tailwindcss/src/utils/topological-sort.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ae4c34e85..3c161e571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 6d13f4678..3b2f0712c 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/index.test.ts b/packages/tailwindcss/src/index.test.ts index edddb55f0..cfd0d1db8 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', () => { diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index 4be573efb..ca2a0b0df 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,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 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,10 +434,18 @@ 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) { @@ -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) diff --git a/packages/tailwindcss/src/utils/topological-sort.ts b/packages/tailwindcss/src/utils/topological-sort.ts new file mode 100644 index 000000000..ae20da4aa --- /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 +} diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index 7fba81d20..4e5c46894 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' @@ -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 +}