Skip to content

Commit ee2e190

Browse files
committed
implement different approach
Instead of trying to traverse ASTs and tracking unused variables in various spots, let's reverse the algorithm. ALWAYS emit everything, this has the added benefit that emitting CSS variables and keyframes is only done once (in the `compile(…)` step, not in each `build(…)` step). However, the removal of the unused CSS variables is part of the `optimizeAst` step. This has a few benefits: 1. All the logic is done in a single spot, instead of tracking all over the codebase. 2. We are already traversing for the optimization step, so we can hook in without additional walks of the entire AST. 3. It will be more correct, e.g.: if you have a valid utility, but invalid variant, since the utility is handled first it could be that we mark variables as used even though the no CSS will be generated due to the invalid variant. Since this the `optimizeAst` step never even sees the thrown-away variant+utility, it means that we don't even have to worry about this. Had to add a context node, to different between `:root {}` coming from `@theme` or coming from user CSS (which should stay untouched).
1 parent f7fa77c commit ee2e190

File tree

6 files changed

+116
-102
lines changed

6 files changed

+116
-102
lines changed

packages/tailwindcss/src/ast.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { parseAtRule } from './css-parser'
2+
import type { DesignSystem } from './design-system'
3+
import { ThemeOptions } from './theme'
4+
import { DefaultMap } from './utils/default-map'
5+
import * as ValueParser from './value-parser'
26

37
const AT_SIGN = 0x40
48

@@ -237,9 +241,13 @@ export function walkDepth(
237241

238242
// Optimize the AST for printing where all the special nodes that require custom
239243
// handling are handled such that the printing is a 1-to-1 transformation.
240-
export function optimizeAst(ast: AstNode[]) {
244+
export function optimizeAst(ast: AstNode[], designSystem: DesignSystem) {
241245
let atRoots: AstNode[] = []
242246
let seenAtProperties = new Set<string>()
247+
let cssThemeVariables = new DefaultMap<
248+
Extract<AstNode, { nodes: AstNode[] }>['nodes'],
249+
Set<Declaration>
250+
>(() => new Set())
243251

244252
function transform(
245253
node: AstNode,
@@ -251,6 +259,22 @@ export function optimizeAst(ast: AstNode[]) {
251259
if (node.property === '--tw-sort' || node.value === undefined || node.value === null) {
252260
return
253261
}
262+
263+
// Track used CSS variables
264+
if (node.value.includes('var(')) {
265+
ValueParser.walk(ValueParser.parse(node.value), (node) => {
266+
if (node.kind !== 'function' || node.value !== 'var') return
267+
268+
ValueParser.walk(node.nodes, (child) => {
269+
if (child.kind !== 'word' || child.value[0] !== '-' || child.value[1] !== '-') return
270+
271+
designSystem.theme.markUsedVariable(child.value)
272+
})
273+
274+
return ValueParser.ValueWalkAction.Skip
275+
})
276+
}
277+
254278
parent.push(node)
255279
}
256280

@@ -331,6 +355,15 @@ export function optimizeAst(ast: AstNode[]) {
331355
return
332356
}
333357

358+
if (node.context.theme) {
359+
let declarations = cssThemeVariables.get(parent)
360+
for (let child of node.nodes) {
361+
if (child.kind === 'declaration') {
362+
declarations.add(child)
363+
}
364+
}
365+
}
366+
334367
for (let child of node.nodes) {
335368
transform(child, parent, depth)
336369
}
@@ -352,6 +385,35 @@ export function optimizeAst(ast: AstNode[]) {
352385
transform(node, newAst, 0)
353386
}
354387

388+
// Remove unused theme variables
389+
next: for (let [parent, declarations] of cssThemeVariables) {
390+
for (let declaration of declarations) {
391+
let options = designSystem.theme.getOptions(declaration.property)
392+
if (options & ThemeOptions.USED) continue
393+
394+
// Remove the declaration (from its parent)
395+
let idx = parent.indexOf(declaration)
396+
parent.splice(idx, 1)
397+
398+
// If the parent is now empty, remove it from the AST
399+
if (parent.length === 0) {
400+
for (let [idx, node] of newAst.entries()) {
401+
// Assumption, but right now the `@theme` must be top-level, so we
402+
// don't need to traverse the entire AST to find the parent.
403+
//
404+
// Checking for `rule`, because at this stage the `@theme` is already
405+
// converted to a normal style rule `:root, :host`
406+
if (node.kind === 'rule' && node.nodes === parent) {
407+
newAst.splice(idx, 1)
408+
break
409+
}
410+
}
411+
412+
continue next
413+
}
414+
}
415+
}
416+
355417
return newAst.concat(atRoots)
356418
}
357419

packages/tailwindcss/src/compile.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,6 @@ export function compileAstNodes(candidate: Candidate, designSystem: DesignSystem
168168
if (result === null) return []
169169
}
170170

171-
// Mark CSS variables as used. Variables resolved internally will already be
172-
// marked as used. This is purely for arbitrary values and properties that use
173-
// variables. E.g.: `[--color:var(--color-red-500)]`
174-
//
175-
//
176-
if (candidate.raw.includes('var(')) {
177-
designSystem.theme.trackUsedVariables([node])
178-
}
179-
180171
rules.push({
181172
node,
182173
propertySort,

packages/tailwindcss/src/design-system.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
6565
},
6666
})
6767

68-
astNodes = optimizeAst(astNodes)
68+
astNodes = optimizeAst(astNodes, designSystem)
6969

7070
if (astNodes.length === 0 || wasInvalid) {
7171
result.push(null)

packages/tailwindcss/src/index.ts

Lines changed: 40 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
atRoot,
55
atRule,
66
comment,
7+
context,
78
context as contextNode,
89
decl,
910
optimizeAst,
@@ -532,6 +533,39 @@ async function parseCss(
532533
customUtility(designSystem)
533534
}
534535

536+
// Output final set of theme variables at the position of the first
537+
// `@theme` rule.
538+
if (firstThemeRule) {
539+
let nodes = []
540+
541+
for (let [key, value] of designSystem.theme.entries()) {
542+
if (value.options & ThemeOptions.REFERENCE) continue
543+
544+
nodes.push(decl(escape(key), value.value))
545+
}
546+
547+
let keyframesRules = designSystem.theme.getKeyframes()
548+
if (keyframesRules.length > 0) {
549+
let animationParts = [...designSystem.theme.namespace('--animate').values()].flatMap(
550+
(animation) => animation.split(/\s+/),
551+
)
552+
553+
for (let keyframesRule of keyframesRules) {
554+
// Remove any keyframes that aren't used by an animation variable.
555+
let keyframesName = keyframesRule.params
556+
if (!animationParts.includes(keyframesName)) {
557+
continue
558+
}
559+
560+
// Wrap `@keyframes` in `AtRoot` so they are hoisted out of `:root` when
561+
// printing.
562+
nodes.push(atRoot([keyframesRule]))
563+
}
564+
}
565+
566+
firstThemeRule.nodes = [context({ theme: true }, nodes)]
567+
}
568+
535569
// Replace the `@tailwind utilities` node with a context since it prints
536570
// children directly.
537571
if (utilitiesNode) {
@@ -567,10 +601,6 @@ async function parseCss(
567601
features |= substituteFunctions(ast, designSystem)
568602
features |= substituteAtApply(ast, designSystem)
569603

570-
// Mark CSS variables as used. Right now they can only be used in
571-
// declarations, because `@media` and `@container` don't support them.
572-
designSystem.theme.trackUsedVariables(ast)
573-
574604
// Remove `@utility`, we couldn't replace it before yet because we had to
575605
// handle the nested `@apply` at-rules first.
576606
walk(ast, (node, { replaceWith }) => {
@@ -592,7 +622,6 @@ async function parseCss(
592622
root,
593623
utilitiesNode,
594624
features,
595-
firstThemeRule,
596625
}
597626
}
598627

@@ -605,10 +634,7 @@ export async function compileAst(
605634
features: Features
606635
build(candidates: string[]): AstNode[]
607636
}> {
608-
let { designSystem, ast, globs, root, utilitiesNode, features, firstThemeRule } = await parseCss(
609-
input,
610-
opts,
611-
)
637+
let { designSystem, ast, globs, root, utilitiesNode, features } = await parseCss(input, opts)
612638

613639
if (process.env.NODE_ENV !== 'test') {
614640
ast.unshift(comment(`! tailwindcss v${version} | MIT License | https://tailwindcss.com `))
@@ -636,7 +662,7 @@ export async function compileAst(
636662
}
637663

638664
if (!utilitiesNode) {
639-
compiled ??= optimizeAst(ast)
665+
compiled ??= optimizeAst(ast, designSystem)
640666
return compiled
641667
}
642668

@@ -648,7 +674,7 @@ export async function compileAst(
648674
for (let candidate of newRawCandidates) {
649675
if (!designSystem.invalidCandidates.has(candidate)) {
650676
if (candidate[0] === '-' && candidate[1] === '-') {
651-
emitNewCssVariables = designSystem.theme.use(candidate)
677+
emitNewCssVariables = designSystem.theme.markUsedVariable(candidate)
652678
didChange ||= emitNewCssVariables
653679
} else {
654680
allValidCandidates.add(candidate)
@@ -660,61 +686,27 @@ export async function compileAst(
660686
// If no new candidates were added, we can return the original CSS. This
661687
// currently assumes that we only add new candidates and never remove any.
662688
if (!didChange) {
663-
compiled ??= optimizeAst(ast)
689+
compiled ??= optimizeAst(ast, designSystem)
664690
return compiled
665691
}
666692

667693
let newNodes = compileCandidates(allValidCandidates, designSystem, {
668694
onInvalidCandidate,
669695
}).astNodes
670696

671-
// Output final set of theme variables at the position of the first
672-
// `@theme` rule.
673-
if (firstThemeRule) {
674-
let nodes = []
675-
676-
for (let [key, value] of designSystem.theme.entries()) {
677-
if (value.options & ThemeOptions.REFERENCE) continue
678-
if (!(value.options & ThemeOptions.USED)) continue
679-
680-
nodes.push(decl(escape(key), value.value))
681-
}
682-
683-
let keyframesRules = designSystem.theme.getKeyframes()
684-
if (keyframesRules.length > 0) {
685-
let animationParts = [...designSystem.theme.namespace('--animate').values()].flatMap(
686-
(animation) => animation.split(/\s+/),
687-
)
688-
689-
for (let keyframesRule of keyframesRules) {
690-
// Remove any keyframes that aren't used by an animation variable.
691-
let keyframesName = keyframesRule.params
692-
if (!animationParts.includes(keyframesName)) {
693-
continue
694-
}
695-
696-
// Wrap `@keyframes` in `AtRoot` so they are hoisted out of `:root` when
697-
// printing.
698-
nodes.push(atRoot([keyframesRule]))
699-
}
700-
}
701-
702-
firstThemeRule.nodes = nodes
703-
}
704-
705697
// If no new ast nodes were generated, then we can return the original
706698
// CSS. This currently assumes that we only add new ast nodes and never
707699
// remove any.
708700
if (previousAstNodeCount === newNodes.length && !emitNewCssVariables) {
709-
compiled ??= optimizeAst(ast)
701+
compiled ??= optimizeAst(ast, designSystem)
710702
return compiled
711703
}
712704

713705
previousAstNodeCount = newNodes.length
714706

715707
utilitiesNode.nodes = newNodes
716708

717-
compiled = optimizeAst(ast)
709+
compiled = optimizeAst(ast, designSystem)
718710
return compiled
719711
},
720712
}

packages/tailwindcss/src/theme.ts

Lines changed: 11 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { walk, WalkAction, type AstNode, type AtRule } from './ast'
1+
import { type AtRule } from './ast'
22
import { escape } from './utils/escape'
3-
import * as ValueParser from './value-parser'
43

54
export const enum ThemeOptions {
65
NONE = 0,
@@ -108,7 +107,7 @@ export class Theme {
108107
}
109108

110109
getOptions(key: string) {
111-
return this.values.get(key)?.options ?? ThemeOptions.NONE
110+
return this.values.get(this.#unprefixKey(key))?.options ?? ThemeOptions.NONE
112111
}
113112

114113
entries() {
@@ -125,6 +124,11 @@ export class Theme {
125124
return `--${this.prefix}-${key.slice(2)}`
126125
}
127126

127+
#unprefixKey(key: string) {
128+
if (!this.prefix) return key
129+
return `--${key.slice(3 + this.prefix.length)}`
130+
}
131+
128132
clearNamespace(namespace: string, clearOptions: ThemeOptions) {
129133
let ignored = ignoredThemeKeyMap.get(namespace) ?? []
130134

@@ -174,42 +178,13 @@ export class Theme {
174178
return null
175179
}
176180

177-
this.use(themeKey)
178-
179181
return `var(${escape(this.#prefixKey(themeKey))})`
180182
}
181183

182-
trackUsedVariables(ast: AstNode[]) {
183-
walk(ast, (node) => {
184-
// Variables used in `@utility` and `@custom-variant` at-rules will be
185-
// handled separately, because we only want to mark them as used if the
186-
// utility or variant is used.
187-
if (
188-
node.kind === 'at-rule' &&
189-
(node.name === '@utility' || node.name === '@custom-variant')
190-
) {
191-
return WalkAction.Skip
192-
}
193-
194-
if (node.kind !== 'declaration') return
195-
if (!node.value?.includes('var(')) return
196-
197-
ValueParser.walk(ValueParser.parse(node.value), (node) => {
198-
if (node.kind !== 'function' || node.value !== 'var') return
199-
200-
ValueParser.walk(node.nodes, (child) => {
201-
if (child.kind !== 'word' || child.value[0] !== '-' || child.value[1] !== '-') return
202-
203-
this.use(child.value)
204-
})
205-
})
206-
})
207-
}
208-
209-
use(themeKey: string) {
210-
let value = this.values.get(themeKey)
211-
if (!value) return false // Unknown
212-
if (value.options & ThemeOptions.USED) return false // Already used
184+
markUsedVariable(themeKey: string) {
185+
let value = this.values.get(this.#unprefixKey(themeKey))
186+
if (!value) return false // Unknown variable
187+
if (value.options & ThemeOptions.USED) return false // Variable already used
213188

214189
value.options |= ThemeOptions.USED
215190
return true

packages/tailwindcss/src/utilities.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4824,8 +4824,6 @@ export function createCssUtility(node: AtRule) {
48244824
}
48254825
}
48264826

4827-
designSystem.theme.trackUsedVariables(atRule.nodes)
4828-
48294827
return atRule.nodes
48304828
})
48314829

@@ -4846,11 +4844,7 @@ export function createCssUtility(node: AtRule) {
48464844

48474845
if (IS_VALID_STATIC_UTILITY_NAME.test(name)) {
48484846
return (designSystem: DesignSystem) => {
4849-
designSystem.utilities.static(name, () => {
4850-
let ast = structuredClone(node.nodes)
4851-
designSystem.theme.trackUsedVariables(ast)
4852-
return ast
4853-
})
4847+
designSystem.utilities.static(name, () => structuredClone(node.nodes))
48544848
}
48554849
}
48564850

0 commit comments

Comments
 (0)