Skip to content

Commit 02cfc45

Browse files
authored
Ensure @utility is processed before using them (#15542)
This PR fixes an issue where using an `@utility` before it is defined, and _if_ that `@utility` contains `@apply`, that it won't result in the expected output. But results in an `@apply` rule that is not substituted. Additionally, if you have multiple levels of `@apply`, we have to make sure that everything is applied (no pun intended) in the right order. Right now, the following steps are taken: 1. Collect all the `@utility` at-rules (and register them in the system as utilities). 2. Substitute `@apply` on the AST (including `@utility`'s ASTs) with the content of the utility. 3. Delete the `@utility` at-rules such that they are removed from the CSS output itself. The reason we do it in this order is because handling `@apply` during `@utility` handling means that we could rely on another `@utility` that is defined later and therefore the order of the utilities starts to matter. This is not a bad thing, but the moment you import multiple CSS files or plugins, this could become hard to manage. Another important step is that when using `@utility foo`, the implementation creates a `structuredClone` from its AST when first using the utility. The reason we do that is because `foo` and `foo!` generate different output and we don't want to accidentally mutate the same AST. This structured clone is the start of the problem in the linked issue (#15501). If we don't do the structured clone, then substituting the `@apply` rules would work, but then `foo` and `foo!` will generate the same output, which is bad. The linked issue has this structure: ```css .foo { @apply bar; } @Utility bar { @apply flex; } ``` If we follow the steps above, this would substitute `@apply bar` first, which results in: ```css .foo { @apply flex; } ``` But the `bar` utility, was already cloned (and cached) so now we end up with an `@apply` rule that is not substituted. To properly solve this problem, we have to make sure that we collect all the `@apply` at-rules, and apply them in the correct order. To do this, we run a topological sort on them which ensures that all the dependencies are applied before substituting the current `@apply`. This means that in the above example, in order to process `@apply bar`, we have to process the `bar` utility first. If we run into a circular dependency, then we will throw an error like before. You'll notice that the error message in this PR is updated to a different spot. This one is a bit easier to grasp because it shows the error where the circular dependency _starts_ not where it _ends_ (and completes the circle). The previous message was not wrong (since it's a circle), but now it's a bit easier to reason about. Fixes: #15501
1 parent dcf116b commit 02cfc45

File tree

5 files changed

+237
-37
lines changed

5 files changed

+237
-37
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
- Fix `inset-shadow-*` suggestions in IntelliSense ([#15471](https://github.com/tailwindlabs/tailwindcss/pull/15471))
2020
- Only compile arbitrary values ending in `]` ([#15503](https://github.com/tailwindlabs/tailwindcss/pull/15503))
2121
- Improve performance and memory usage ([#15529](https://github.com/tailwindlabs/tailwindcss/pull/15529))
22+
- Ensure `@apply` rules are processed in the correct order ([#15542](https://github.com/tailwindlabs/tailwindcss/pull/15542))
2223
- _Upgrade (experimental)_: Do not extract class names from functions (e.g. `shadow` in `filter: 'drop-shadow(…)'`) ([#15566](https://github.com/tailwindlabs/tailwindcss/pull/15566))
2324

2425
### Changed

packages/tailwindcss/src/apply.ts

Lines changed: 157 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
11
import { Features } from '.'
2-
import { walk, WalkAction, type AstNode } from './ast'
2+
import { rule, toCss, walk, WalkAction, type AstNode } from './ast'
33
import { compileCandidates } from './compile'
44
import type { DesignSystem } from './design-system'
5-
import { escape } from './utils/escape'
5+
import { DefaultMap } from './utils/default-map'
66

77
export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
88
let features = Features.None
9-
walk(ast, (node, { replaceWith }) => {
9+
10+
// Wrap the whole AST in a root rule to make sure there is always a parent
11+
// available for `@apply` at-rules. In some cases, the incoming `ast` just
12+
// contains `@apply` at-rules which means that there is no proper parent to
13+
// rely on.
14+
let root = rule('&', ast)
15+
16+
// Track all nodes containing `@apply`
17+
let parents = new Set<AstNode>()
18+
19+
// Track all the dependencies of an `AstNode`
20+
let dependencies = new DefaultMap<AstNode, Set<string>>(() => new Set<string>())
21+
22+
// Track all `@utility` definitions by its root (name)
23+
let definitions = new DefaultMap(() => new Set<AstNode>())
24+
25+
// Collect all new `@utility` definitions and all `@apply` rules first
26+
walk([root], (node, { parent }) => {
1027
if (node.kind !== 'at-rule') return
1128

1229
// Do not allow `@apply` rules inside `@keyframes` rules.
@@ -19,9 +36,119 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
1936
return WalkAction.Skip
2037
}
2138

22-
if (node.name !== '@apply') return
23-
features |= Features.AtApply
39+
// `@utility` defines a utility, which is important information in order to
40+
// do a correct topological sort later on.
41+
if (node.name === '@utility') {
42+
let name = node.params.replace(/-\*$/, '')
43+
definitions.get(name).add(node)
44+
45+
// In case `@apply` rules are used inside `@utility` rules.
46+
walk(node.nodes, (child) => {
47+
if (child.kind !== 'at-rule' || child.name !== '@apply') return
48+
49+
parents.add(node)
50+
51+
for (let dependency of resolveApplyDependencies(child, designSystem)) {
52+
dependencies.get(node).add(dependency)
53+
}
54+
})
55+
return
56+
}
57+
58+
// Any other `@apply` node.
59+
if (node.name === '@apply') {
60+
// `@apply` cannot be top-level, so we need to have a parent such that we
61+
// can replace the `@apply` node with the actual utility classes later.
62+
if (parent === null) return
63+
64+
features |= Features.AtApply
65+
66+
parents.add(parent)
67+
68+
for (let dependency of resolveApplyDependencies(node, designSystem)) {
69+
dependencies.get(parent).add(dependency)
70+
}
71+
}
72+
})
73+
74+
// Topological sort before substituting `@apply`
75+
let seen = new Set<AstNode>()
76+
let sorted: AstNode[] = []
77+
let wip = new Set<AstNode>()
78+
79+
function visit(node: AstNode, path: AstNode[] = []) {
80+
if (seen.has(node)) {
81+
return
82+
}
83+
84+
// Circular dependency detected
85+
if (wip.has(node)) {
86+
// Next node in the path is the one that caused the circular dependency
87+
let next = path[(path.indexOf(node) + 1) % path.length]
88+
89+
if (
90+
node.kind === 'at-rule' &&
91+
node.name === '@utility' &&
92+
next.kind === 'at-rule' &&
93+
next.name === '@utility'
94+
) {
95+
walk(node.nodes, (child) => {
96+
if (child.kind !== 'at-rule' || child.name !== '@apply') return
97+
98+
let candidates = child.params.split(/\s+/g)
99+
for (let candidate of candidates) {
100+
for (let candidateAstNode of designSystem.parseCandidate(candidate)) {
101+
switch (candidateAstNode.kind) {
102+
case 'arbitrary':
103+
break
24104

105+
case 'static':
106+
case 'functional':
107+
if (next.params.replace(/-\*$/, '') === candidateAstNode.root) {
108+
throw new Error(
109+
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
110+
)
111+
}
112+
break
113+
114+
default:
115+
candidateAstNode satisfies never
116+
}
117+
}
118+
}
119+
})
120+
}
121+
122+
// Generic fallback error in case we cannot properly detect the origin of
123+
// the circular dependency.
124+
throw new Error(
125+
`Circular dependency detected:\n\n${toCss([node])}\nRelies on:\n\n${toCss([next])}`,
126+
)
127+
}
128+
129+
wip.add(node)
130+
131+
for (let dependencyId of dependencies.get(node)) {
132+
for (let dependency of definitions.get(dependencyId)) {
133+
path.push(node)
134+
visit(dependency, path)
135+
path.pop()
136+
}
137+
}
138+
139+
seen.add(node)
140+
wip.delete(node)
141+
142+
sorted.push(node)
143+
}
144+
145+
for (let node of parents) {
146+
visit(node)
147+
}
148+
149+
// Substitute the `@apply` at-rules in order
150+
walk(sorted, (node, { replaceWith }) => {
151+
if (node.kind !== 'at-rule' || node.name !== '@apply') return
25152
let candidates = node.params.split(/\s+/g)
26153

27154
// Replace the `@apply` rule with the actual utility classes
@@ -48,35 +175,33 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
48175
}
49176
}
50177

51-
// Verify that we don't have any circular dependencies by verifying that
52-
// the current node does not appear in the new nodes.
53-
walk(newNodes, (child) => {
54-
if (child !== node) return
55-
56-
// At this point we already know that we have a circular dependency.
57-
//
58-
// Figure out which candidate caused the circular dependency. This will
59-
// help to create a useful error message for the end user.
60-
for (let candidate of candidates) {
61-
let selector = `.${escape(candidate)}`
62-
63-
for (let rule of candidateAst) {
64-
if (rule.kind !== 'rule') continue
65-
if (rule.selector !== selector) continue
66-
67-
walk(rule.nodes, (child) => {
68-
if (child !== node) return
69-
70-
throw new Error(
71-
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
72-
)
73-
})
74-
}
75-
}
76-
})
77-
78178
replaceWith(newNodes)
79179
}
80180
})
181+
81182
return features
82183
}
184+
185+
function* resolveApplyDependencies(
186+
node: Extract<AstNode, { kind: 'at-rule' }>,
187+
designSystem: DesignSystem,
188+
) {
189+
for (let candidate of node.params.split(/\s+/g)) {
190+
for (let node of designSystem.parseCandidate(candidate)) {
191+
switch (node.kind) {
192+
case 'arbitrary':
193+
// Doesn't matter, because there is no lookup needed
194+
break
195+
196+
case 'static':
197+
case 'functional':
198+
// Lookup by "root"
199+
yield node.root
200+
break
201+
202+
default:
203+
node satisfies never
204+
}
205+
}
206+
}
207+
}

packages/tailwindcss/src/index.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,53 @@ describe('@apply', () => {
461461
}"
462462
`)
463463
})
464+
465+
it('should recursively apply with custom `@utility`, which is used before it is defined', async () => {
466+
expect(
467+
await compileCss(
468+
css`
469+
@tailwind utilities;
470+
471+
@layer base {
472+
body {
473+
@apply a;
474+
}
475+
}
476+
477+
@utility a {
478+
@apply b;
479+
}
480+
481+
@utility b {
482+
@apply focus:c;
483+
}
484+
485+
@utility c {
486+
@apply my-flex!;
487+
}
488+
489+
@utility my-flex {
490+
@apply flex;
491+
}
492+
`,
493+
['a', 'b', 'c', 'flex', 'my-flex'],
494+
),
495+
).toMatchInlineSnapshot(`
496+
".a:focus, .b:focus, .c {
497+
display: flex !important;
498+
}
499+
500+
.flex, .my-flex {
501+
display: flex;
502+
}
503+
504+
@layer base {
505+
body:focus {
506+
display: flex !important;
507+
}
508+
}"
509+
`)
510+
})
464511
})
465512

466513
describe('arbitrary variants', () => {

packages/tailwindcss/src/index.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -538,10 +538,8 @@ async function parseCss(
538538
node.context = {}
539539
}
540540

541-
// Replace `@apply` rules with the actual utility classes.
542-
features |= substituteAtApply(ast, designSystem)
543-
544541
features |= substituteFunctions(ast, designSystem.resolveThemeValue)
542+
features |= substituteAtApply(ast, designSystem)
545543

546544
// Remove `@utility`, we couldn't replace it before yet because we had to
547545
// handle the nested `@apply` at-rules first.

packages/tailwindcss/src/utilities.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17375,7 +17375,7 @@ describe('custom utilities', () => {
1737517375
['foo', 'bar'],
1737617376
),
1737717377
).rejects.toThrowErrorMatchingInlineSnapshot(
17378-
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
17378+
`[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`,
1737917379
)
1738017380
})
1738117381

@@ -17406,7 +17406,36 @@ describe('custom utilities', () => {
1740617406
['foo', 'bar'],
1740717407
),
1740817408
).rejects.toThrowErrorMatchingInlineSnapshot(
17409-
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
17409+
`[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`,
17410+
)
17411+
})
17412+
17413+
test('custom utilities with `@apply` causing circular dependencies should error (multiple levels)', async () => {
17414+
await expect(() =>
17415+
compileCss(
17416+
css`
17417+
body {
17418+
@apply foo;
17419+
}
17420+
17421+
@utility foo {
17422+
@apply flex-wrap hover:bar;
17423+
}
17424+
17425+
@utility bar {
17426+
@apply flex dark:baz;
17427+
}
17428+
17429+
@utility baz {
17430+
@apply flex-wrap hover:foo;
17431+
}
17432+
17433+
@tailwind utilities;
17434+
`,
17435+
['foo', 'bar'],
17436+
),
17437+
).rejects.toThrowErrorMatchingInlineSnapshot(
17438+
`[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`,
1741017439
)
1741117440
})
1741217441
})

0 commit comments

Comments
 (0)