Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/tailwindcss/src/compat/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i kinda wish we didn't have to pass the design system into something hanging off the design system :D

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, one way of solving this is that we put a variantFromAst onto the DesignSystem itself 🤔

or, we can pass in a callback if we want to do something with the ast (body in this case) so the logic lives in the callsite.

I think for now, this is fine because it's all private anyway.

}
},
matchVariant(name, fn, options) {
Expand Down
174 changes: 174 additions & 0 deletions packages/tailwindcss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
71 changes: 39 additions & 32 deletions packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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]+$/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -390,7 +392,7 @@ async function parseCss(
}
}

customVariants.push((designSystem) => {
customVariants.set(name, (designSystem) => {
designSystem.variants.static(
name,
(r) => {
Expand All @@ -411,6 +413,7 @@ async function parseCss(
},
)
})
customVariantDependencies.set(name, new Set<string>())

return
}
Expand All @@ -431,9 +434,17 @@ 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
}
Expand Down Expand Up @@ -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, () => {})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open for suggestions here. Like maybe a designSystem.variants.reserve(name) but felt silly to introduce a new method just for this...

But my idea was to have the same behavior as-if you are overwriting internal variants that should maintain the sort order.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's an argument for keeping the position of the lastly defined @custom-variant because otherwise if you were relying on a library that now introduces a @custom-variant with the same name, the sort order will be different.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is fine. Keeping the last defined one would probably be more CSS-y but it would make overriding builtin variants different from custom ones and I'd prefer that they act the same.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep agree

}

// 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) {
Expand Down Expand Up @@ -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)

Expand Down
36 changes: 36 additions & 0 deletions packages/tailwindcss/src/utils/topological-sort.ts
Original file line number Diff line number Diff line change
@@ -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[] = []
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could make topologicalSort a Generator which has a nice API, but that's way slower compared to this array-based implementation:

Image

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this with a very simple graph:

let graph = new Map<string, Set<string>>([
  ['A', new Set(['B', 'C'])],
  ['B', new Set(['D'])],
  ['C', new Set(['D'])],
  ['D', new Set(['E'])],
  ['E', new Set()],
])

But I don't think the real graphs will be much more complex anyway...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'd keep the array in this case. The perf tradeoff makes sense when the memory hit is possibly unbounded but you're likely to hit other perf and memory problems with large CSS files first.


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
}
Loading