Skip to content

Ensure @utility is processed before using them #15542

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

Merged
merged 11 commits into from
Jan 7, 2025
Prev Previous commit
Next Next commit
use a topological sort when substituting @apply
  • Loading branch information
RobinMalfait committed Jan 7, 2025
commit 05f8b1a981a915923920e06b977d7217f5069e99
183 changes: 151 additions & 32 deletions packages/tailwindcss/src/apply.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { Features } from '.'
import { walk, WalkAction, type AstNode } from './ast'
import { 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 }) => {

// 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(ast, (node, { parent }) => {
if (node.kind !== 'at-rule') return

// Do not allow `@apply` rules inside `@keyframes` rules.
Expand All @@ -19,9 +30,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>()

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 +169,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
}
}
}
}
10 changes: 6 additions & 4 deletions packages/tailwindcss/src/compat/plugin-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,8 +268,9 @@ export function buildPluginApi(

designSystem.utilities.static(className, () => {
let clonedAst = structuredClone(ast)
featuresRef.current |= substituteAtApply(clonedAst, designSystem)
return clonedAst
let parent = rule('&', clonedAst)
featuresRef.current |= substituteAtApply([parent], designSystem)
return parent.nodes
})
}
},
Expand Down Expand Up @@ -390,8 +391,9 @@ export function buildPluginApi(
}

let ast = objectToAst(fn(value, { modifier }))
featuresRef.current |= substituteAtApply(ast, designSystem)
return ast
let parent = rule('&', ast)
featuresRef.current |= substituteAtApply([parent], designSystem)
return parent.nodes
}
}

Expand Down