mirror of
https://github.com/tailwindlabs/tailwindcss.git
synced 2025-12-08 21:36:08 +00:00
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:
parent
b01ff53f2a
commit
7d73e51359
@ -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
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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.]`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user