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
This commit is contained in:
Robin Malfait 2024-08-09 16:09:25 +02:00 committed by GitHub
parent b01ff53f2a
commit 7d73e51359
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 248 additions and 38 deletions

View File

@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for explicitly registering content paths using new `@source` at-rule ([#14078](https://github.com/tailwindlabs/tailwindcss/pull/14078))
- Add support for scanning `<style>` tags in Vue files to the Vite plugin ([#14158](https://github.com/tailwindlabs/tailwindcss/pull/14158))
### Fixed
- Ensure `@apply` works inside `@utility` ([#14144](https://github.com/tailwindlabs/tailwindcss/pull/14144))
## [4.0.0-alpha.18] - 2024-07-25
### Added

View File

@ -326,6 +326,39 @@ describe('@apply', () => {
}"
`)
})
it('should be possible to apply a custom utility', async () => {
expect(
await compileCss(css`
@utility bar {
&:before {
content: 'bar';
}
}
.foo {
/* Baz is defined after this rule, but should work */
@apply bar baz;
}
@utility baz {
&:after {
content: 'baz';
}
}
@tailwind utilities;
`),
).toMatchInlineSnapshot(`
".foo:before {
content: "bar";
}
.foo:after {
content: "baz";
}"
`)
})
})
describe('arbitrary variants', () => {

View File

@ -15,6 +15,7 @@ import { compileCandidates } from './compile'
import * as CSS from './css-parser'
import { buildDesignSystem, type DesignSystem } from './design-system'
import { Theme } from './theme'
import { escape } from './utils/escape'
import { segment } from './utils/segment'
const IS_VALID_UTILITY_NAME = /^[a-z][a-zA-Z0-9/%._-]*$/
@ -117,7 +118,6 @@ export async function compile(
})
})
replaceWith([])
return
}
@ -338,8 +338,8 @@ export async function compile(
let tailwindUtilitiesNode: Rule | null = null
// Find `@tailwind utilities` and replace it with the actual generated utility
// class CSS.
// Find `@tailwind utilities` so that we can later replace it with the actual
// generated utility class CSS.
walk(ast, (node) => {
if (node.kind === 'rule' && node.selector === '@tailwind utilities') {
tailwindUtilitiesNode = node
@ -353,42 +353,23 @@ export async function compile(
// Replace `@apply` rules with the actual utility classes.
if (css.includes('@apply')) {
walk(ast, (node, { replaceWith }) => {
if (node.kind === 'rule' && node.selector[0] === '@' && node.selector.startsWith('@apply')) {
let candidates = node.selector
.slice(7 /* Ignore `@apply ` when parsing the selector */)
.trim()
.split(/\s+/g)
// Replace the `@apply` rule with the actual utility classes
{
// Parse the candidates to an AST that we can replace the `@apply` rule with.
let candidateAst = compileCandidates(candidates, designSystem, {
onInvalidCandidate: (candidate) => {
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
},
}).astNodes
// Collect the nodes to insert in place of the `@apply` rule. When a
// rule was used, we want to insert its children instead of the rule
// because we don't want the wrapping selector.
let newNodes: AstNode[] = []
for (let candidateNode of candidateAst) {
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
for (let child of candidateNode.nodes) {
newNodes.push(child)
}
} else {
newNodes.push(candidateNode)
}
}
replaceWith(newNodes)
}
}
})
substituteAtApply(ast, designSystem)
}
// Remove `@utility`, we couldn't replace it before yet because we had to
// handle the nested `@apply` at-rules first.
walk(ast, (node, { replaceWith }) => {
if (node.kind !== 'rule') return
if (node.selector[0] === '@' && node.selector.startsWith('@utility ')) {
replaceWith([])
}
// The `@utility` has to be top-level, therefore we don't have to traverse
// into nested trees.
return WalkAction.Skip
})
// Track all valid candidates, these are the incoming `rawCandidate` that
// resulted in a generated AST Node. All the other `rawCandidates` are invalid
// and should be ignored.
@ -439,6 +420,72 @@ export async function compile(
}
}
function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
walk(ast, (node, { replaceWith }) => {
if (node.kind !== 'rule') return
if (!(node.selector[0] === '@' && node.selector.startsWith('@apply '))) return
let candidates = node.selector
.slice(7 /* Ignore `@apply ` when parsing the selector */)
.trim()
.split(/\s+/g)
// Replace the `@apply` rule with the actual utility classes
{
// Parse the candidates to an AST that we can replace the `@apply` rule
// with.
let candidateAst = compileCandidates(candidates, designSystem, {
onInvalidCandidate: (candidate) => {
throw new Error(`Cannot apply unknown utility class: ${candidate}`)
},
}).astNodes
// Collect the nodes to insert in place of the `@apply` rule. When a rule
// was used, we want to insert its children instead of the rule because we
// don't want the wrapping selector.
let newNodes: AstNode[] = []
for (let candidateNode of candidateAst) {
if (candidateNode.kind === 'rule' && candidateNode.selector[0] !== '@') {
for (let child of candidateNode.nodes) {
newNodes.push(child)
}
} else {
newNodes.push(candidateNode)
}
}
// Verify that we don't have any circular dependencies by verifying that
// the current node does not appear in the new nodes.
walk(newNodes, (child) => {
if (child !== node) return
// At this point we already know that we have a circular dependency.
//
// Figure out which candidate caused the circular dependency. This will
// help to create a useful error message for the end user.
for (let candidate of candidates) {
let selector = `.${escape(candidate)}`
for (let rule of candidateAst) {
if (rule.kind !== 'rule') continue
if (rule.selector !== selector) continue
walk(rule.nodes, (child) => {
if (child !== node) return
throw new Error(
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
)
})
}
}
})
replaceWith(newNodes)
}
})
}
export function __unstable__loadDesignSystem(css: string) {
// Find all `@theme` declarations
let theme = new Theme()

View File

@ -15122,7 +15122,7 @@ describe('custom utilities', () => {
`)
})
test('custom utilities support some special chracters', async () => {
test('custom utilities support some special characters', async () => {
let { build } = await compile(css`
@layer utilities {
@tailwind utilities;
@ -15268,4 +15268,130 @@ describe('custom utilities', () => {
`),
).rejects.toThrowError(/should be alphanumeric/)
})
test('custom utilities work with `@apply`', async () => {
expect(
await compileCss(
css`
@utility foo {
@apply flex flex-col underline;
}
@utility bar {
@apply z-10;
.baz {
@apply z-20;
}
}
@tailwind utilities;
`,
['foo', 'hover:foo', 'bar'],
),
).toMatchInlineSnapshot(`
".bar {
z-index: 10;
}
.bar .baz {
z-index: 20;
}
.foo {
flex-direction: column;
text-decoration-line: underline;
display: flex;
}
.hover\\:foo:hover {
flex-direction: column;
text-decoration-line: underline;
display: flex;
}"
`)
})
test('referencing custom utilities in custom utilities via `@apply` should work', async () => {
expect(
await compileCss(
css`
@utility foo {
@apply flex flex-col underline;
}
@utility bar {
@apply dark:foo font-bold;
}
@tailwind utilities;
`,
['bar'],
),
).toMatchInlineSnapshot(`
".bar {
font-weight: 700;
}
@media (prefers-color-scheme: dark) {
.bar {
flex-direction: column;
text-decoration-line: underline;
display: flex;
}
}"
`)
})
test('custom utilities with `@apply` causing circular dependencies should error', async () => {
await expect(() =>
compileCss(
css`
@utility foo {
@apply font-bold hover:bar;
}
@utility bar {
@apply flex dark:foo;
}
@tailwind utilities;
`,
['foo', 'bar'],
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
)
})
test('custom utilities with `@apply` causing circular dependencies should error (deeply nesting)', async () => {
await expect(() =>
compileCss(
css`
@utility foo {
.bar {
.baz {
.qux {
@apply font-bold hover:bar;
}
}
}
}
@utility bar {
.baz {
.qux {
@apply flex dark:foo;
}
}
}
@tailwind utilities;
`,
['foo', 'bar'],
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
)
})
})