Skip to content

Commit

Permalink
Use AST transformations in @tailwindcss/postcss (#15297)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
RobinMalfait and thecrypticace authored Dec 4, 2024
1 parent 536e118 commit 408fa99
Show file tree
Hide file tree
Showing 12 changed files with 558 additions and 172 deletions.
32 changes: 23 additions & 9 deletions crates/oxide/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,18 @@ impl Scanner {
fn compute_candidates(&mut self) {
let mut changed_content = vec![];

for path in &self.files {
let current_time = fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now());
let current_mtimes = self
.files
.par_iter()
.map(|path| {
fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now())
})
.collect::<Vec<_>>();

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

let should_scan_file = match previous_time {
Expand Down Expand Up @@ -218,14 +225,21 @@ impl Scanner {

#[tracing::instrument(skip_all)]
fn check_for_new_files(&mut self) {
let current_mtimes = self
.dirs
.par_iter()
.map(|path| {
fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now())
})
.collect::<Vec<_>>();

let mut modified_dirs: Vec<PathBuf> = vec![];

// Check all directories to see if they were modified
for path in &self.dirs {
let current_time = fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::now());

for (idx, path) in self.dirs.iter().enumerate() {
let current_time = current_mtimes[idx];
let previous_time = self.mtimes.insert(path.clone(), current_time);

let should_scan = match previous_time {
Expand Down
1 change: 0 additions & 1 deletion crates/oxide/src/scanner/allowed_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ pub fn resolve_paths(root: &Path) -> impl Iterator<Item = DirEntry> {
.filter_map(Result::ok)
}

#[tracing::instrument(skip_all)]
pub fn read_dir(root: &Path, depth: Option<usize>) -> impl Iterator<Item = DirEntry> {
WalkBuilder::new(root)
.hidden(false)
Expand Down
60 changes: 38 additions & 22 deletions packages/@tailwindcss-node/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,40 @@ import { pathToFileURL } from 'node:url'
import {
__unstable__loadDesignSystem as ___unstable__loadDesignSystem,
compile as _compile,
compileAst as _compileAst,
Features,
} from 'tailwindcss'
import type { AstNode } from '../../tailwindcss/src/ast'
import { getModuleDependencies } from './get-module-dependencies'
import { rewriteUrls } from './urls'

export { Features }

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

export async function compile(
css: string,
{
base,
onDependency,
shouldRewriteUrls,

customCssResolver,
customJsResolver,
}: {
base: string
onDependency: (path: string) => void
shouldRewriteUrls?: boolean

customCssResolver?: Resolver
customJsResolver?: Resolver
},
) {
let compiler = await _compile(css, {
export interface CompileOptions {
base: string
onDependency: (path: string) => void
shouldRewriteUrls?: boolean

customCssResolver?: Resolver
customJsResolver?: Resolver
}

function createCompileOptions({
base,
onDependency,
shouldRewriteUrls,

customCssResolver,
customJsResolver,
}: CompileOptions) {
return {
base,
async loadModule(id, base) {
async loadModule(id: string, base: string) {
return loadModule(id, base, onDependency, customJsResolver)
},
async loadStylesheet(id, base) {
async loadStylesheet(id: string, base: string) {
let sheet = await loadStylesheet(id, base, onDependency, customCssResolver)

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

return sheet
},
})
}
}

async function ensureSourceDetectionRootExists(
compiler: { root: Awaited<ReturnType<typeof compile>>['root'] },
base: string,
) {
// Verify if the `source(…)` path exists (until the glob pattern starts)
if (compiler.root && compiler.root !== 'none') {
let globSymbols = /[*{]/
Expand All @@ -75,7 +81,17 @@ export async function compile(
throw new Error(`The \`source(${compiler.root.pattern})\` does not exist`)
}
}
}

export async function compileAst(ast: AstNode[], options: CompileOptions) {
let compiler = await _compileAst(ast, createCompileOptions(options))
await ensureSourceDetectionRootExists(compiler, options.base)
return compiler
}

export async function compile(css: string, options: CompileOptions) {
let compiler = await _compile(css, createCompileOptions(options))
await ensureSourceDetectionRootExists(compiler, options.base)
return compiler
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@tailwindcss-node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Module from 'node:module'
import { pathToFileURL } from 'node:url'
import * as env from './env'
export { __unstable__loadDesignSystem, compile, Features } from './compile'
export { __unstable__loadDesignSystem, compile, compileAst, Features } from './compile'
export * from './normalize-path'
export { env }

Expand Down
107 changes: 107 additions & 0 deletions packages/@tailwindcss-postcss/src/ast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import dedent from 'dedent'
import postcss from 'postcss'
import { expect, it } from 'vitest'
import { toCss } from '../../tailwindcss/src/ast'
import { parse } from '../../tailwindcss/src/css-parser'
import { cssAstToPostCssAst, postCssAstToCssAst } from './ast'

let css = dedent

it('should convert a PostCSS AST into a Tailwind CSS AST', () => {
let input = css`
@charset "UTF-8";
@layer foo, bar, baz;
@import 'tailwindcss';
.foo {
color: red;
&:hover {
color: blue;
}
.bar {
color: green !important;
background-color: yellow;
@media (min-width: 640px) {
color: orange;
}
}
}
`

let ast = postcss.parse(input)
let transformedAst = postCssAstToCssAst(ast)

expect(toCss(transformedAst)).toMatchInlineSnapshot(`
"@charset "UTF-8";
@layer foo, bar, baz;
@import 'tailwindcss';
.foo {
color: red;
&:hover {
color: blue;
}
.bar {
color: green !important;
background-color: yellow;
@media (min-width: 640px) {
color: orange;
}
}
}
"
`)
})

it('should convert a Tailwind CSS AST into a PostCSS AST', () => {
let input = css`
@charset "UTF-8";
@layer foo, bar, baz;
@import 'tailwindcss';
.foo {
color: red;
&:hover {
color: blue;
}
.bar {
color: green !important;
background-color: yellow;
@media (min-width: 640px) {
color: orange;
}
}
}
`

let ast = parse(input)
let transformedAst = cssAstToPostCssAst(ast)

expect(transformedAst.toString()).toMatchInlineSnapshot(`
"@charset "UTF-8";
@layer foo, bar, baz;
@import 'tailwindcss';
.foo {
color: red;
&:hover {
color: blue;
}
.bar {
color: green !important;
background-color: yellow;
@media (min-width: 640px) {
color: orange;
}
}
}"
`)
})
117 changes: 117 additions & 0 deletions packages/@tailwindcss-postcss/src/ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import postcss, {
type ChildNode as PostCssChildNode,
type Container as PostCssContainerNode,
type Root as PostCssRoot,
type Source as PostcssSource,
} from 'postcss'
import { atRule, comment, decl, rule, type AstNode } from '../../tailwindcss/src/ast'

const EXCLAMATION_MARK = 0x21

export function cssAstToPostCssAst(ast: AstNode[], source: PostcssSource | undefined): PostCssRoot {
let root = postcss.root()
root.source = source

function transform(node: AstNode, parent: PostCssContainerNode) {
// Declaration
if (node.kind === 'declaration') {
let astNode = postcss.decl({
prop: node.property,
value: node.value ?? '',
important: node.important,
})
astNode.source = source
parent.append(astNode)
}

// Rule
else if (node.kind === 'rule') {
let astNode = postcss.rule({ selector: node.selector })
astNode.source = source
astNode.raws.semicolon = true
parent.append(astNode)
for (let child of node.nodes) {
transform(child, astNode)
}
}

// AtRule
else if (node.kind === 'at-rule') {
let astNode = postcss.atRule({ name: node.name.slice(1), params: node.params })
astNode.source = source
astNode.raws.semicolon = true
parent.append(astNode)
for (let child of node.nodes) {
transform(child, astNode)
}
}

// Comment
else if (node.kind === 'comment') {
let astNode = postcss.comment({ text: node.value })
// Spaces are encoded in our node.value already, no need to add additional
// spaces.
astNode.raws.left = ''
astNode.raws.right = ''
astNode.source = source
parent.append(astNode)
}

// AtRoot & Context should not happen
else if (node.kind === 'at-root' || node.kind === 'context') {
}

// Unknown
else {
node satisfies never
}
}

for (let node of ast) {
transform(node, root)
}

return root
}

export function postCssAstToCssAst(root: PostCssRoot): AstNode[] {
function transform(
node: PostCssChildNode,
parent: Extract<AstNode, { nodes: AstNode[] }>['nodes'],
) {
// Declaration
if (node.type === 'decl') {
parent.push(decl(node.prop, node.value, node.important))
}

// Rule
else if (node.type === 'rule') {
let astNode = rule(node.selector)
node.each((child) => transform(child, astNode.nodes))
parent.push(astNode)
}

// AtRule
else if (node.type === 'atrule') {
let astNode = atRule(`@${node.name}`, node.params)
node.each((child) => transform(child, astNode.nodes))
parent.push(astNode)
}

// Comment
else if (node.type === 'comment') {
if (node.text.charCodeAt(0) !== EXCLAMATION_MARK) return
parent.push(comment(node.text))
}

// Unknown
else {
node satisfies never
}
}

let ast: AstNode[] = []
root.each((node) => transform(node, ast))

return ast
}
Loading

0 comments on commit 408fa99

Please sign in to comment.