Skip to content

Commit 7d73e51

Browse files
authored
Ensure @apply works inside @utility (#14144)
This PR fixes an issue where `@apply` was not handled if it was used inside of `@utility`. You should now be able to do something like this: ```css @Utility btn { @apply flex flex-col bg-white p-4 rounded-lg shadow-md; } ``` If you then use `btn` as a class, the following CSS will be generated: ```css .btn { border-radius: var(--radius-lg, .5rem); background-color: var(--color-white, #fff); padding: var(--spacing-4, 1rem); --tw-shadow: 0 4px 6px -1px #0000001a, 0 2px 4px -2px #0000001a; --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); flex-direction: column; display: flex; } ``` This PR also makes sure that you can use custom `@utility` inside other `@utility` via `@apply`. E.g.: ```css @Utility foo { color: red; } @Utility bar { color: red; @apply hover:foo; } ``` If we detect a circular dependency, then we will throw an error since circular dependencies are not allowed. E.g.: ```css @Utility foo { @apply hover:bar; } @Utility bar { @apply focus:baz; } @Utility baz { @apply dark:foo; } ``` Regardless of which utility you use, eventually it will apply itself. Fixes: #14143
1 parent b01ff53 commit 7d73e51

File tree

4 files changed

+248
-38
lines changed

4 files changed

+248
-38
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Add support for explicitly registering content paths using new `@source` at-rule ([#14078](https://github.com/tailwindlabs/tailwindcss/pull/14078))
1515
- Add support for scanning `<style>` tags in Vue files to the Vite plugin ([#14158](https://github.com/tailwindlabs/tailwindcss/pull/14158))
1616

17+
### Fixed
18+
19+
- Ensure `@apply` works inside `@utility` ([#14144](https://github.com/tailwindlabs/tailwindcss/pull/14144))
20+
1721
## [4.0.0-alpha.18] - 2024-07-25
1822

1923
### Added

packages/tailwindcss/src/index.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,39 @@ describe('@apply', () => {
326326
}"
327327
`)
328328
})
329+
330+
it('should be possible to apply a custom utility', async () => {
331+
expect(
332+
await compileCss(css`
333+
@utility bar {
334+
&:before {
335+
content: 'bar';
336+
}
337+
}
338+
339+
.foo {
340+
/* Baz is defined after this rule, but should work */
341+
@apply bar baz;
342+
}
343+
344+
@utility baz {
345+
&:after {
346+
content: 'baz';
347+
}
348+
}
349+
350+
@tailwind utilities;
351+
`),
352+
).toMatchInlineSnapshot(`
353+
".foo:before {
354+
content: "bar";
355+
}
356+
357+
.foo:after {
358+
content: "baz";
359+
}"
360+
`)
361+
})
329362
})
330363

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

packages/tailwindcss/src/index.ts

Lines changed: 84 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { compileCandidates } from './compile'
1515
import * as CSS from './css-parser'
1616
import { buildDesignSystem, type DesignSystem } from './design-system'
1717
import { Theme } from './theme'
18+
import { escape } from './utils/escape'
1819
import { segment } from './utils/segment'
1920

2021
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
@@ -117,7 +118,6 @@ export async function compile(
117118
})
118119
})
119120

120-
replaceWith([])
121121
return
122122
}
123123

@@ -338,8 +338,8 @@ export async function compile(
338338

339339
let tailwindUtilitiesNode: Rule | null = null
340340

341-
// Find `@tailwind utilities` and replace it with the actual generated utility
342-
// class CSS.
341+
// Find `@tailwind utilities` so that we can later replace it with the actual
342+
// generated utility class CSS.
343343
walk(ast, (node) => {
344344
if (node.kind === 'rule' && node.selector === '@tailwind utilities') {
345345
tailwindUtilitiesNode = node
@@ -353,42 +353,23 @@ export async function compile(
353353

354354
// Replace `@apply` rules with the actual utility classes.
355355
if (css.includes('@apply')) {
356-
walk(ast, (node, { replaceWith }) => {
357-
if (node.kind === 'rule' && node.selector[0] === '@' && node.selector.startsWith('@apply')) {
358-
let candidates = node.selector
359-
.slice(7 /* Ignore `@apply ` when parsing the selector */)
360-
.trim()
361-
.split(/\s+/g)
362-
363-
// Replace the `@apply` rule with the actual utility classes
364-
{
365-
// Parse the candidates to an AST that we can replace the `@apply` rule with.
366-
let candidateAst = compileCandidates(candidates, designSystem, {
367-
onInvalidCandidate: (candidate) => {
368-
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
369-
},
370-
}).astNodes
371-
372-
// Collect the nodes to insert in place of the `@apply` rule. When a
373-
// rule was used, we want to insert its children instead of the rule
374-
// because we don't want the wrapping selector.
375-
let newNodes: AstNode[] = []
376-
for (let candidateNode of candidateAst) {
377-
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
378-
for (let child of candidateNode.nodes) {
379-
newNodes.push(child)
380-
}
381-
} else {
382-
newNodes.push(candidateNode)
383-
}
384-
}
385-
386-
replaceWith(newNodes)
387-
}
388-
}
389-
})
356+
substituteAtApply(ast, designSystem)
390357
}
391358

359+
// Remove `@utility`, we couldn't replace it before yet because we had to
360+
// handle the nested `@apply` at-rules first.
361+
walk(ast, (node, { replaceWith }) => {
362+
if (node.kind !== 'rule') return
363+
364+
if (node.selector[0] === '@' && node.selector.startsWith('@utility ')) {
365+
replaceWith([])
366+
}
367+
368+
// The `@utility` has to be top-level, therefore we don't have to traverse
369+
// into nested trees.
370+
return WalkAction.Skip
371+
})
372+
392373
// Track all valid candidates, these are the incoming `rawCandidate` that
393374
// resulted in a generated AST Node. All the other `rawCandidates` are invalid
394375
// and should be ignored.
@@ -439,6 +420,72 @@ export async function compile(
439420
}
440421
}
441422

423+
function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
424+
walk(ast, (node, { replaceWith }) => {
425+
if (node.kind !== 'rule') return
426+
if (!(node.selector[0] === '@' && node.selector.startsWith('@apply '))) return
427+
428+
let candidates = node.selector
429+
.slice(7 /* Ignore `@apply ` when parsing the selector */)
430+
.trim()
431+
.split(/\s+/g)
432+
433+
// Replace the `@apply` rule with the actual utility classes
434+
{
435+
// Parse the candidates to an AST that we can replace the `@apply` rule
436+
// with.
437+
let candidateAst = compileCandidates(candidates, designSystem, {
438+
onInvalidCandidate: (candidate) => {
439+
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
440+
},
441+
}).astNodes
442+
443+
// Collect the nodes to insert in place of the `@apply` rule. When a rule
444+
// was used, we want to insert its children instead of the rule because we
445+
// don't want the wrapping selector.
446+
let newNodes: AstNode[] = []
447+
for (let candidateNode of candidateAst) {
448+
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
449+
for (let child of candidateNode.nodes) {
450+
newNodes.push(child)
451+
}
452+
} else {
453+
newNodes.push(candidateNode)
454+
}
455+
}
456+
457+
// Verify that we don't have any circular dependencies by verifying that
458+
// the current node does not appear in the new nodes.
459+
walk(newNodes, (child) => {
460+
if (child !== node) return
461+
462+
// At this point we already know that we have a circular dependency.
463+
//
464+
// Figure out which candidate caused the circular dependency. This will
465+
// help to create a useful error message for the end user.
466+
for (let candidate of candidates) {
467+
let selector = `.${escape(candidate)}`
468+
469+
for (let rule of candidateAst) {
470+
if (rule.kind !== 'rule') continue
471+
if (rule.selector !== selector) continue
472+
473+
walk(rule.nodes, (child) => {
474+
if (child !== node) return
475+
476+
throw new Error(
477+
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
478+
)
479+
})
480+
}
481+
}
482+
})
483+
484+
replaceWith(newNodes)
485+
}
486+
})
487+
}
488+
442489
export function __unstable__loadDesignSystem(css: string) {
443490
// Find all `@theme` declarations
444491
let theme = new Theme()

packages/tailwindcss/src/utilities.test.ts

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15122,7 +15122,7 @@ describe('custom utilities', () => {
1512215122
`)
1512315123
})
1512415124

15125-
test('custom utilities support some special chracters', async () => {
15125+
test('custom utilities support some special characters', async () => {
1512615126
let { build } = await compile(css`
1512715127
@layer utilities {
1512815128
@tailwind utilities;
@@ -15268,4 +15268,130 @@ describe('custom utilities', () => {
1526815268
`),
1526915269
).rejects.toThrowError(/should be alphanumeric/)
1527015270
})
15271+
15272+
test('custom utilities work with `@apply`', async () => {
15273+
expect(
15274+
await compileCss(
15275+
css`
15276+
@utility foo {
15277+
@apply flex flex-col underline;
15278+
}
15279+
15280+
@utility bar {
15281+
@apply z-10;
15282+
15283+
.baz {
15284+
@apply z-20;
15285+
}
15286+
}
15287+
15288+
@tailwind utilities;
15289+
`,
15290+
['foo', 'hover:foo', 'bar'],
15291+
),
15292+
).toMatchInlineSnapshot(`
15293+
".bar {
15294+
z-index: 10;
15295+
}
15296+
15297+
.bar .baz {
15298+
z-index: 20;
15299+
}
15300+
15301+
.foo {
15302+
flex-direction: column;
15303+
text-decoration-line: underline;
15304+
display: flex;
15305+
}
15306+
15307+
.hover\\:foo:hover {
15308+
flex-direction: column;
15309+
text-decoration-line: underline;
15310+
display: flex;
15311+
}"
15312+
`)
15313+
})
15314+
15315+
test('referencing custom utilities in custom utilities via `@apply` should work', async () => {
15316+
expect(
15317+
await compileCss(
15318+
css`
15319+
@utility foo {
15320+
@apply flex flex-col underline;
15321+
}
15322+
15323+
@utility bar {
15324+
@apply dark:foo font-bold;
15325+
}
15326+
15327+
@tailwind utilities;
15328+
`,
15329+
['bar'],
15330+
),
15331+
).toMatchInlineSnapshot(`
15332+
".bar {
15333+
font-weight: 700;
15334+
}
15335+
15336+
@media (prefers-color-scheme: dark) {
15337+
.bar {
15338+
flex-direction: column;
15339+
text-decoration-line: underline;
15340+
display: flex;
15341+
}
15342+
}"
15343+
`)
15344+
})
15345+
15346+
test('custom utilities with `@apply` causing circular dependencies should error', async () => {
15347+
await expect(() =>
15348+
compileCss(
15349+
css`
15350+
@utility foo {
15351+
@apply font-bold hover:bar;
15352+
}
15353+
15354+
@utility bar {
15355+
@apply flex dark:foo;
15356+
}
15357+
15358+
@tailwind utilities;
15359+
`,
15360+
['foo', 'bar'],
15361+
),
15362+
).rejects.toThrowErrorMatchingInlineSnapshot(
15363+
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
15364+
)
15365+
})
15366+
15367+
test('custom utilities with `@apply` causing circular dependencies should error (deeply nesting)', async () => {
15368+
await expect(() =>
15369+
compileCss(
15370+
css`
15371+
@utility foo {
15372+
.bar {
15373+
.baz {
15374+
.qux {
15375+
@apply font-bold hover:bar;
15376+
}
15377+
}
15378+
}
15379+
}
15380+
15381+
@utility bar {
15382+
.baz {
15383+
.qux {
15384+
@apply flex dark:foo;
15385+
}
15386+
}
15387+
}
15388+
15389+
@tailwind utilities;
15390+
`,
15391+
['foo', 'bar'],
15392+
),
15393+
).rejects.toThrowErrorMatchingInlineSnapshot(
15394+
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
15395+
)
15396+
})
1527115397
})

0 commit comments

Comments
 (0)