Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ensure @utility is processed before using them #15542

Merged
merged 11 commits into from
Jan 7, 2025
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix `inset-shadow-*` suggestions in IntelliSense ([#15471](https://github.com/tailwindlabs/tailwindcss/pull/15471))
- Only compile arbitrary values ending in `]` ([#15503](https://github.com/tailwindlabs/tailwindcss/pull/15503))
- Improve performance and memory usage ([#15529](https://github.com/tailwindlabs/tailwindcss/pull/15529))
- Ensure `@apply` rules are processed in the correct order ([#15542](https://github.com/tailwindlabs/tailwindcss/pull/15542))

### Changed

Expand Down
189 changes: 157 additions & 32 deletions packages/tailwindcss/src/apply.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
import { Features } from '.'
import { walk, WalkAction, type AstNode } from './ast'
import { rule, toCss, walk, WalkAction, type AstNode } from './ast'
import { compileCandidates } from './compile'
import type { DesignSystem } from './design-system'
import { escape } from './utils/escape'
import { DefaultMap } from './utils/default-map'

export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
let features = Features.None
walk(ast, (node, { replaceWith }) => {

// Wrap the whole AST in a root rule to make sure there is always a parent
// available for `@apply` at-rules. In some cases, the incoming `ast` just
// contains `@apply` at-rules which means that there is no proper parent to
// rely on.
let root = rule('&', ast)

// Track all nodes containing `@apply`
let parents = new Set<AstNode>()

// Track all the dependencies of an `AstNode`
let dependencies = new DefaultMap<AstNode, Set<string>>(() => new Set<string>())

// Track all `@utility` definitions by its root (name)
let definitions = new DefaultMap(() => new Set<AstNode>())

// Collect all new `@utility` definitions and all `@apply` rules first
walk([root], (node, { parent }) => {
if (node.kind !== 'at-rule') return

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

if (node.name !== '@apply') return
features |= Features.AtApply
// `@utility` defines a utility, which is important information in order to
// do a correct topological sort later on.
if (node.name === '@utility') {
let name = node.params.replace(/-\*$/, '')
definitions.get(name).add(node)

// In case `@apply` rules are used inside `@utility` rules.
walk(node.nodes, (child) => {
if (child.kind !== 'at-rule' || child.name !== '@apply') return

parents.add(node)

for (let dependency of resolveApplyDependencies(child, designSystem)) {
dependencies.get(node).add(dependency)
}
})
return
}

// Any other `@apply` node.
if (node.name === '@apply') {
// `@apply` cannot be top-level, so we need to have a parent such that we
// can replace the `@apply` node with the actual utility classes later.
if (parent === null) return

features |= Features.AtApply

parents.add(parent)

for (let dependency of resolveApplyDependencies(node, designSystem)) {
dependencies.get(parent).add(dependency)
}
}
})

// Topological sort before substituting `@apply`
let seen = new Set<AstNode>()
let sorted: AstNode[] = []
let wip = new Set<AstNode>()
thecrypticace marked this conversation as resolved.
Show resolved Hide resolved

function visit(node: AstNode, path: AstNode[] = []) {
if (seen.has(node)) {
return
}

// Circular dependency detected
if (wip.has(node)) {
// Next node in the path is the one that caused the circular dependency
let next = path[(path.indexOf(node) + 1) % path.length]

if (
node.kind === 'at-rule' &&
node.name === '@utility' &&
next.kind === 'at-rule' &&
next.name === '@utility'
) {
walk(node.nodes, (child) => {
if (child.kind !== 'at-rule' || child.name !== '@apply') return

let candidates = child.params.split(/\s+/g)
for (let candidate of candidates) {
for (let candidateAstNode of designSystem.parseCandidate(candidate)) {
switch (candidateAstNode.kind) {
case 'arbitrary':
break

case 'static':
case 'functional':
if (next.params.replace(/-\*$/, '') === candidateAstNode.root) {
throw new Error(
`You cannot \`@apply\` the \`${candidate}\` utility here because it creates a circular dependency.`,
)
}
break

default:
candidateAstNode satisfies never
}
}
}
})
}

// Generic fallback error in case we cannot properly detect the origin of
// the circular dependency.
throw new Error(
`Circular dependency detected:\n\n${toCss([node])}\nRelies on:\n\n${toCss([next])}`,
)
}

wip.add(node)

for (let dependencyId of dependencies.get(node)) {
for (let dependency of definitions.get(dependencyId)) {
path.push(node)
visit(dependency, path)
path.pop()
}
}

seen.add(node)
wip.delete(node)

sorted.push(node)
}

for (let node of parents) {
visit(node)
}

// Substitute the `@apply` at-rules in order
walk(sorted, (node, { replaceWith }) => {
if (node.kind !== 'at-rule' || node.name !== '@apply') return
let candidates = node.params.split(/\s+/g)

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

// 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)
}
})

return features
}

function* resolveApplyDependencies(
node: Extract<AstNode, { kind: 'at-rule' }>,
designSystem: DesignSystem,
) {
for (let candidate of node.params.split(/\s+/g)) {
for (let node of designSystem.parseCandidate(candidate)) {
switch (node.kind) {
case 'arbitrary':
// Doesn't matter, because there is no lookup needed
break

case 'static':
case 'functional':
// Lookup by "root"
yield node.root
break

default:
node satisfies never
}
}
}
}
47 changes: 47 additions & 0 deletions packages/tailwindcss/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,53 @@ describe('@apply', () => {
}"
`)
})

it('should recursively apply with custom `@utility`, which is used before it is defined', async () => {
expect(
await compileCss(
css`
@tailwind utilities;

@layer base {
body {
@apply a;
}
}

@utility a {
@apply b;
}

@utility b {
@apply focus:c;
}

@utility c {
@apply my-flex!;
}

@utility my-flex {
@apply flex;
}
`,
['a', 'b', 'c', 'flex', 'my-flex'],
),
).toMatchInlineSnapshot(`
".a:focus, .b:focus, .c {
display: flex !important;
}

.flex, .my-flex {
display: flex;
}

@layer base {
body:focus {
display: flex !important;
}
}"
`)
})
})

describe('arbitrary variants', () => {
Expand Down
4 changes: 1 addition & 3 deletions packages/tailwindcss/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,10 +538,8 @@ async function parseCss(
node.context = {}
}

// Replace `@apply` rules with the actual utility classes.
features |= substituteAtApply(ast, designSystem)

features |= substituteFunctions(ast, designSystem.resolveThemeValue)
features |= substituteAtApply(ast, designSystem)
Comment on lines 541 to +542
Copy link
Member Author

Choose a reason for hiding this comment

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

Swapped these two around, because substituteAtApply will result in a larger tree, which means that the substituteFunctions can operate on a smaller tree.

Copy link
Contributor

Choose a reason for hiding this comment

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

yay


// Remove `@utility`, we couldn't replace it before yet because we had to
// handle the nested `@apply` at-rules first.
Expand Down
33 changes: 31 additions & 2 deletions packages/tailwindcss/src/utilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17375,7 +17375,7 @@ describe('custom utilities', () => {
['foo', 'bar'],
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
`[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`,
)
})

Expand Down Expand Up @@ -17406,7 +17406,36 @@ describe('custom utilities', () => {
['foo', 'bar'],
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: You cannot \`@apply\` the \`dark:foo\` utility here because it creates a circular dependency.]`,
`[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`,
)
})

test('custom utilities with `@apply` causing circular dependencies should error (multiple levels)', async () => {
await expect(() =>
compileCss(
css`
body {
@apply foo;
}

@utility foo {
@apply flex-wrap hover:bar;
}

@utility bar {
@apply flex dark:baz;
}

@utility baz {
@apply flex-wrap hover:foo;
}

@tailwind utilities;
`,
['foo', 'bar'],
),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[Error: You cannot \`@apply\` the \`hover:bar\` utility here because it creates a circular dependency.]`,
)
})
})