Skip to content
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