Skip to content

Commit 408fa99

Browse files
Use AST transformations in @tailwindcss/postcss (#15297)
This PR improves the `@tailwindcss/postcss` integration by using direct AST transformations between our own AST and PostCSS's AST. This allows us to skip a step where we convert our AST into a string, then parse it back into a PostCSS AST. The only downside is that we still have to print the AST into a string if we want to optimize the CSS using Lightning CSS. Luckily this only happens in production (`NODE_ENV=production`). This also introduces a new private `compileAst` API, that allows us to accept an AST as the input. This allows us to skip the PostCSS AST -> string -> parse into our own AST step. To summarize: Instead of: - Input: `PostCSS AST` -> `.toString()` -> `CSS.parse(…)` -> `Tailwind CSS AST` - Output: `Tailwind CSS AST` -> `toCSS(ast)` -> `postcss.parse(…)` -> `PostCSS AST` We will now do this instead: - Input: `PostCSS AST` -> `transform(…)` -> `Tailwind CSS AST` - Output: `Tailwind CSS AST` -> `transform(…)` -> `PostCSS AST` --- Running this on Catalyst, the time spent in the `@tailwindcss/postcss` looks like this: - Before: median time per run: 19.407687 ms - After: median time per run: 11.8796455 ms This is tested on Catalyst which roughly generates ~208kb worth of CSS in dev mode. While it's not a lot, skipping the stringification and parsing seems to improve this step by ~40%. Note: these times exclude scanning the actual candidates and only time the work needed for parsing/stringifying the CSS from and into ASTs. The actual numbers are a bit higher because of the Oxide scanner reading files from disk. But since that part is going to be there no matter what, it's not fair to include it in this benchmark. --------- Co-authored-by: Jordan Pittman <[email protected]>
1 parent 536e118 commit 408fa99

File tree

12 files changed

+558
-172
lines changed

12 files changed

+558
-172
lines changed

crates/oxide/src/lib.rs

+23-9
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,18 @@ impl Scanner {
171171
fn compute_candidates(&mut self) {
172172
let mut changed_content = vec![];
173173

174-
for path in &self.files {
175-
let current_time = fs::metadata(path)
176-
.and_then(|m| m.modified())
177-
.unwrap_or(SystemTime::now());
174+
let current_mtimes = self
175+
.files
176+
.par_iter()
177+
.map(|path| {
178+
fs::metadata(path)
179+
.and_then(|m| m.modified())
180+
.unwrap_or(SystemTime::now())
181+
})
182+
.collect::<Vec<_>>();
178183

184+
for (idx, path) in self.files.iter().enumerate() {
185+
let current_time = current_mtimes[idx];
179186
let previous_time = self.mtimes.insert(path.clone(), current_time);
180187

181188
let should_scan_file = match previous_time {
@@ -218,14 +225,21 @@ impl Scanner {
218225

219226
#[tracing::instrument(skip_all)]
220227
fn check_for_new_files(&mut self) {
228+
let current_mtimes = self
229+
.dirs
230+
.par_iter()
231+
.map(|path| {
232+
fs::metadata(path)
233+
.and_then(|m| m.modified())
234+
.unwrap_or(SystemTime::now())
235+
})
236+
.collect::<Vec<_>>();
237+
221238
let mut modified_dirs: Vec<PathBuf> = vec![];
222239

223240
// Check all directories to see if they were modified
224-
for path in &self.dirs {
225-
let current_time = fs::metadata(path)
226-
.and_then(|m| m.modified())
227-
.unwrap_or(SystemTime::now());
228-
241+
for (idx, path) in self.dirs.iter().enumerate() {
242+
let current_time = current_mtimes[idx];
229243
let previous_time = self.mtimes.insert(path.clone(), current_time);
230244

231245
let should_scan = match previous_time {

crates/oxide/src/scanner/allowed_paths.rs

-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
4040
.filter_map(Result::ok)
4141
}
4242

43-
#[tracing::instrument(skip_all)]
4443
pub fn read_dir(root: &Path, depth: Option<usize>) -> impl Iterator<Item = DirEntry> {
4544
WalkBuilder::new(root)
4645
.hidden(false)

packages/@tailwindcss-node/src/compile.ts

+38-22
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,40 @@ import { pathToFileURL } from 'node:url'
77
import {
88
__unstable__loadDesignSystem as ___unstable__loadDesignSystem,
99
compile as _compile,
10+
compileAst as _compileAst,
1011
Features,
1112
} from 'tailwindcss'
13+
import type { AstNode } from '../../tailwindcss/src/ast'
1214
import { getModuleDependencies } from './get-module-dependencies'
1315
import { rewriteUrls } from './urls'
1416

1517
export { Features }
1618

1719
export type Resolver = (id: string, base: string) => Promise<string | false | undefined>
1820

19-
export async function compile(
20-
css: string,
21-
{
22-
base,
23-
onDependency,
24-
shouldRewriteUrls,
25-
26-
customCssResolver,
27-
customJsResolver,
28-
}: {
29-
base: string
30-
onDependency: (path: string) => void
31-
shouldRewriteUrls?: boolean
32-
33-
customCssResolver?: Resolver
34-
customJsResolver?: Resolver
35-
},
36-
) {
37-
let compiler = await _compile(css, {
21+
export interface CompileOptions {
22+
base: string
23+
onDependency: (path: string) => void
24+
shouldRewriteUrls?: boolean
25+
26+
customCssResolver?: Resolver
27+
customJsResolver?: Resolver
28+
}
29+
30+
function createCompileOptions({
31+
base,
32+
onDependency,
33+
shouldRewriteUrls,
34+
35+
customCssResolver,
36+
customJsResolver,
37+
}: CompileOptions) {
38+
return {
3839
base,
39-
async loadModule(id, base) {
40+
async loadModule(id: string, base: string) {
4041
return loadModule(id, base, onDependency, customJsResolver)
4142
},
42-
async loadStylesheet(id, base) {
43+
async loadStylesheet(id: string, base: string) {
4344
let sheet = await loadStylesheet(id, base, onDependency, customCssResolver)
4445

4546
if (shouldRewriteUrls) {
@@ -52,8 +53,13 @@ export async function compile(
5253

5354
return sheet
5455
},
55-
})
56+
}
57+
}
5658

59+
async function ensureSourceDetectionRootExists(
60+
compiler: { root: Awaited<ReturnType<typeof compile>>['root'] },
61+
base: string,
62+
) {
5763
// Verify if the `source(…)` path exists (until the glob pattern starts)
5864
if (compiler.root && compiler.root !== 'none') {
5965
let globSymbols = /[*{]/
@@ -75,7 +81,17 @@ export async function compile(
7581
throw new Error(`The \`source(${compiler.root.pattern})\` does not exist`)
7682
}
7783
}
84+
}
85+
86+
export async function compileAst(ast: AstNode[], options: CompileOptions) {
87+
let compiler = await _compileAst(ast, createCompileOptions(options))
88+
await ensureSourceDetectionRootExists(compiler, options.base)
89+
return compiler
90+
}
7891

92+
export async function compile(css: string, options: CompileOptions) {
93+
let compiler = await _compile(css, createCompileOptions(options))
94+
await ensureSourceDetectionRootExists(compiler, options.base)
7995
return compiler
8096
}
8197

packages/@tailwindcss-node/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as Module from 'node:module'
22
import { pathToFileURL } from 'node:url'
33
import * as env from './env'
4-
export { __unstable__loadDesignSystem, compile, Features } from './compile'
4+
export { __unstable__loadDesignSystem, compile, compileAst, Features } from './compile'
55
export * from './normalize-path'
66
export { env }
77

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import dedent from 'dedent'
2+
import postcss from 'postcss'
3+
import { expect, it } from 'vitest'
4+
import { toCss } from '../../tailwindcss/src/ast'
5+
import { parse } from '../../tailwindcss/src/css-parser'
6+
import { cssAstToPostCssAst, postCssAstToCssAst } from './ast'
7+
8+
let css = dedent
9+
10+
it('should convert a PostCSS AST into a Tailwind CSS AST', () => {
11+
let input = css`
12+
@charset "UTF-8";
13+
14+
@layer foo, bar, baz;
15+
16+
@import 'tailwindcss';
17+
18+
.foo {
19+
color: red;
20+
21+
&:hover {
22+
color: blue;
23+
}
24+
25+
.bar {
26+
color: green !important;
27+
background-color: yellow;
28+
29+
@media (min-width: 640px) {
30+
color: orange;
31+
}
32+
}
33+
}
34+
`
35+
36+
let ast = postcss.parse(input)
37+
let transformedAst = postCssAstToCssAst(ast)
38+
39+
expect(toCss(transformedAst)).toMatchInlineSnapshot(`
40+
"@charset "UTF-8";
41+
@layer foo, bar, baz;
42+
@import 'tailwindcss';
43+
.foo {
44+
color: red;
45+
&:hover {
46+
color: blue;
47+
}
48+
.bar {
49+
color: green !important;
50+
background-color: yellow;
51+
@media (min-width: 640px) {
52+
color: orange;
53+
}
54+
}
55+
}
56+
"
57+
`)
58+
})
59+
60+
it('should convert a Tailwind CSS AST into a PostCSS AST', () => {
61+
let input = css`
62+
@charset "UTF-8";
63+
64+
@layer foo, bar, baz;
65+
66+
@import 'tailwindcss';
67+
68+
.foo {
69+
color: red;
70+
71+
&:hover {
72+
color: blue;
73+
}
74+
75+
.bar {
76+
color: green !important;
77+
background-color: yellow;
78+
79+
@media (min-width: 640px) {
80+
color: orange;
81+
}
82+
}
83+
}
84+
`
85+
86+
let ast = parse(input)
87+
let transformedAst = cssAstToPostCssAst(ast)
88+
89+
expect(transformedAst.toString()).toMatchInlineSnapshot(`
90+
"@charset "UTF-8";
91+
@layer foo, bar, baz;
92+
@import 'tailwindcss';
93+
.foo {
94+
color: red;
95+
&:hover {
96+
color: blue;
97+
}
98+
.bar {
99+
color: green !important;
100+
background-color: yellow;
101+
@media (min-width: 640px) {
102+
color: orange;
103+
}
104+
}
105+
}"
106+
`)
107+
})
+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import postcss, {
2+
type ChildNode as PostCssChildNode,
3+
type Container as PostCssContainerNode,
4+
type Root as PostCssRoot,
5+
type Source as PostcssSource,
6+
} from 'postcss'
7+
import { atRule, comment, decl, rule, type AstNode } from '../../tailwindcss/src/ast'
8+
9+
const EXCLAMATION_MARK = 0x21
10+
11+
export function cssAstToPostCssAst(ast: AstNode[], source: PostcssSource | undefined): PostCssRoot {
12+
let root = postcss.root()
13+
root.source = source
14+
15+
function transform(node: AstNode, parent: PostCssContainerNode) {
16+
// Declaration
17+
if (node.kind === 'declaration') {
18+
let astNode = postcss.decl({
19+
prop: node.property,
20+
value: node.value ?? '',
21+
important: node.important,
22+
})
23+
astNode.source = source
24+
parent.append(astNode)
25+
}
26+
27+
// Rule
28+
else if (node.kind === 'rule') {
29+
let astNode = postcss.rule({ selector: node.selector })
30+
astNode.source = source
31+
astNode.raws.semicolon = true
32+
parent.append(astNode)
33+
for (let child of node.nodes) {
34+
transform(child, astNode)
35+
}
36+
}
37+
38+
// AtRule
39+
else if (node.kind === 'at-rule') {
40+
let astNode = postcss.atRule({ name: node.name.slice(1), params: node.params })
41+
astNode.source = source
42+
astNode.raws.semicolon = true
43+
parent.append(astNode)
44+
for (let child of node.nodes) {
45+
transform(child, astNode)
46+
}
47+
}
48+
49+
// Comment
50+
else if (node.kind === 'comment') {
51+
let astNode = postcss.comment({ text: node.value })
52+
// Spaces are encoded in our node.value already, no need to add additional
53+
// spaces.
54+
astNode.raws.left = ''
55+
astNode.raws.right = ''
56+
astNode.source = source
57+
parent.append(astNode)
58+
}
59+
60+
// AtRoot & Context should not happen
61+
else if (node.kind === 'at-root' || node.kind === 'context') {
62+
}
63+
64+
// Unknown
65+
else {
66+
node satisfies never
67+
}
68+
}
69+
70+
for (let node of ast) {
71+
transform(node, root)
72+
}
73+
74+
return root
75+
}
76+
77+
export function postCssAstToCssAst(root: PostCssRoot): AstNode[] {
78+
function transform(
79+
node: PostCssChildNode,
80+
parent: Extract<AstNode, { nodes: AstNode[] }>['nodes'],
81+
) {
82+
// Declaration
83+
if (node.type === 'decl') {
84+
parent.push(decl(node.prop, node.value, node.important))
85+
}
86+
87+
// Rule
88+
else if (node.type === 'rule') {
89+
let astNode = rule(node.selector)
90+
node.each((child) => transform(child, astNode.nodes))
91+
parent.push(astNode)
92+
}
93+
94+
// AtRule
95+
else if (node.type === 'atrule') {
96+
let astNode = atRule(`@${node.name}`, node.params)
97+
node.each((child) => transform(child, astNode.nodes))
98+
parent.push(astNode)
99+
}
100+
101+
// Comment
102+
else if (node.type === 'comment') {
103+
if (node.text.charCodeAt(0) !== EXCLAMATION_MARK) return
104+
parent.push(comment(node.text))
105+
}
106+
107+
// Unknown
108+
else {
109+
node satisfies never
110+
}
111+
}
112+
113+
let ast: AstNode[] = []
114+
root.each((node) => transform(node, ast))
115+
116+
return ast
117+
}

0 commit comments

Comments
 (0)