Improve performance of cloning AST nodes (#19067)

This PR improves the performance of when we need to clone some AST
nodes. We have a few places where we clone `Candidate`, `Variant` and
CSS `AST` nodes.

Right now we use `structuredClone`, which works, but it is a generic
solution. However, we do know the exact structure of these AST nodes, so
we can write specialized clone functions that are much faster.

## Test plan

1. All the tests still pass with this change
2. The performance is better:

```
  cloneCandidate - src/candidate.bench.ts > Candidate cloning
    1.72x faster than cloneCandidate (spread)
    74.03x faster than structuredClone

  cloneAstNode() - src/ast.bench.ts > Cloning AST nodes
    1.15x faster than cloneAstNode (with spread)
    33.54x faster than structuredClone()
```

Ready for review, but should be merged after #19059
This commit is contained in:
Robin Malfait 2025-10-07 12:46:37 +02:00 • committed by GitHub
parent b77971f754
commit efe084b7e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 312 additions and 37 deletions

View file

@ -1,5 +1,5 @@
import { Scanner } from '@tailwindcss/oxide'
import type { Candidate } from '../../../../tailwindcss/src/candidate'
import { cloneCandidate, type Candidate } from '../../../../tailwindcss/src/candidate'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
export async function extractRawCandidates(
@ -18,7 +18,7 @@ export async function extractRawCandidates(
// Create a basic stripped candidate without variants or important flag
export function baseCandidate<T extends Candidate>(candidate: T) {
let base = structuredClone(candidate)
let base = cloneCandidate(candidate)
base.important = false
base.variants = []

View file

@ -1,3 +1,4 @@
import { cloneCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import {
@ -22,7 +23,7 @@ export function migrateArbitraryVariants(
// The below logic makes use of mutation. Since candidates in the
// DesignSystem are cached, we can't mutate them directly.
let candidate = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
let candidate = cloneCandidate(readonlyCandidate) as Writable<typeof readonlyCandidate>
for (let [variant] of walkVariants(candidate)) {
if (variant.kind === 'compound') continue

View file

@ -1,7 +1,8 @@
import { walk, WalkAction } from '../../../../tailwindcss/src/ast'
import { type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
import { cloneCandidate, type Candidate, type Variant } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import type { Writable } from '../../../../tailwindcss/src/types'
import * as ValueParser from '../../../../tailwindcss/src/value-parser'
export function migrateAutomaticVarInjection(
@ -12,7 +13,7 @@ export function migrateAutomaticVarInjection(
for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) {
// The below logic makes extended use of mutation. Since candidates in the
// DesignSystem are cached, we can't mutate them directly.
let candidate = structuredClone(readonlyCandidate) as Candidate
let candidate = cloneCandidate(readonlyCandidate) as Writable<Candidate>
let didChange = false

View file

@ -1,3 +1,4 @@
import { cloneCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import * as version from '../../utils/version'
@ -15,7 +16,7 @@ export function migrateCamelcaseInNamedValue(
for (let candidate of designSystem.parseCandidate(rawCandidate)) {
if (candidate.kind !== 'functional') continue
let clone = structuredClone(candidate)
let clone = cloneCandidate(candidate)
let didChange = false
if (

View file

@ -1,4 +1,4 @@
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import { cloneCandidate, parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { segment } from '../../../../tailwindcss/src/utils/segment'
@ -9,7 +9,7 @@ export function migrateLegacyArbitraryValues(
rawCandidate: string,
): string {
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
let clone = structuredClone(candidate)
let clone = cloneCandidate(candidate)
let changed = false
// Convert commas to spaces. E.g.: [auto,1fr] to [auto_1fr]

View file

@ -1,7 +1,7 @@
import { __unstable__loadDesignSystem } from '@tailwindcss/node'
import path from 'node:path'
import url from 'node:url'
import type { Candidate } from '../../../../tailwindcss/src/candidate'
import { cloneCandidate, type Candidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { DefaultMap } from '../../../../tailwindcss/src/utils/default-map'
@ -100,7 +100,7 @@ export async function migrateLegacyClasses(
// Re-apply the variants and important flag from the original candidate.
// E.g.: `hover:blur!` -> `blur` -> `blur-sm` -> `hover:blur-sm!`
let newCandidate = structuredClone(newBaseCandidate) as Candidate
let newCandidate = cloneCandidate(newBaseCandidate) as Candidate
newCandidate.variants = candidate.variants
newCandidate.important = candidate.important

View file

@ -1,4 +1,4 @@
import { parseCandidate } from '../../../../tailwindcss/src/candidate'
import { cloneCandidate, parseCandidate } from '../../../../tailwindcss/src/candidate'
import type { Config } from '../../../../tailwindcss/src/compat/plugin-api'
import type { DesignSystem } from '../../../../tailwindcss/src/design-system'
import { replaceObject } from '../../../../tailwindcss/src/utils/replace-object'
@ -10,7 +10,7 @@ export function migrateModernizeArbitraryValues(
rawCandidate: string,
): string {
for (let candidate of parseCandidate(rawCandidate, designSystem)) {
let clone = structuredClone(candidate)
let clone = cloneCandidate(candidate)
let changed = false
for (let [variant] of walkVariants(clone)) {

View file

@ -1,5 +1,5 @@
import { Features } from '.'
import { rule, toCss, walk, WalkAction, type AstNode } from './ast'
import { cloneAstNode, rule, toCss, walk, WalkAction, type AstNode } from './ast'
import { compileCandidates } from './compile'
import type { DesignSystem } from './design-system'
import type { SourceLocation } from './source-maps/source'
@ -249,7 +249,7 @@ export function substituteAtApply(ast: AstNode[], designSystem: DesignSystem) {
let candidate = compiled.nodeSorting.get(node)?.candidate
let candidateOffset = candidate ? candidateOffsets[candidate] : undefined
node = structuredClone(node)
node = cloneAstNode(node)
if (!src || !candidate || candidateOffset === undefined) {
// While the original nodes may have come from an `@utility` we still

View file

@ -1,5 +1,5 @@
import { bench } from 'vitest'
import { toCss } from './ast'
import { bench, describe } from 'vitest'
import { cloneAstNode, toCss, type AstNode } from './ast'
import * as CSS from './css-parser'
const css = String.raw
@ -19,10 +19,46 @@ const input = css`
`
const ast = CSS.parse(input)
bench('toCss', () => {
toCss(ast)
describe('AST to CSS', () => {
bench('toCss', () => {
toCss(ast)
})
bench('toCss with source maps', () => {
toCss(ast, true)
})
})
bench('toCss with source maps', () => {
toCss(ast, true)
describe('Cloning AST nodes', () => {
bench('cloneAstNode()', () => {
ast.map(cloneAstNode)
})
bench('cloneAstNode (with spread)', () => {
ast.map(cloneAstNodeSpread)
})
bench('structuredClone()', () => {
structuredClone(ast)
})
})
function cloneAstNodeSpread<T extends AstNode>(node: T): T {
switch (node.kind) {
case 'rule':
case 'at-rule':
case 'at-root':
return { ...node, nodes: node.nodes.map(cloneAstNodeSpread) }
case 'context':
return { ...node, context: { ...node.context }, nodes: node.nodes.map(cloneAstNodeSpread) }
case 'declaration':
case 'comment':
return { ...node }
default:
node satisfies never
throw new Error(`Unknown node kind: ${(node as any).kind}`)
}
}

View file

@ -122,6 +122,68 @@ export function atRoot(nodes: AstNode[]): AtRoot {
}
}
export function cloneAstNode<T extends AstNode>(node: T): T {
switch (node.kind) {
case 'rule':
return {
kind: node.kind,
selector: node.selector,
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies StyleRule as T
case 'at-rule':
return {
kind: node.kind,
name: node.name,
params: node.params,
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies AtRule as T
case 'at-root':
return {
kind: node.kind,
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies AtRoot as T
case 'context':
return {
kind: node.kind,
context: { ...node.context },
nodes: node.nodes.map(cloneAstNode),
src: node.src,
dst: node.dst,
} satisfies Context as T
case 'declaration':
return {
kind: node.kind,
property: node.property,
value: node.value,
important: node.important,
src: node.src,
dst: node.dst,
} satisfies Declaration as T
case 'comment':
return {
kind: node.kind,
value: node.value,
src: node.src,
dst: node.dst,
} satisfies Comment as T
default:
node satisfies never
throw new Error(`Unknown node kind: ${(node as any).kind}`)
}
}
export const enum WalkAction {
/** Continue walking, which is the default */
Continue,

View file

@ -1,6 +1,6 @@
import { Scanner } from '@tailwindcss/oxide'
import { bench } from 'vitest'
import { parseCandidate } from './candidate'
import { bench, describe } from 'vitest'
import { cloneCandidate, parseCandidate, type Candidate, type Variant } from './candidate'
import { buildDesignSystem } from './design-system'
import { Theme } from './theme'
@ -8,13 +8,87 @@ import { Theme } from './theme'
const root = process.env.FOLDER || process.cwd()
// Auto content detection
const scanner = new Scanner({ sources: [{ base: root, pattern: '**/*' }] })
const scanner = new Scanner({ sources: [{ base: root, pattern: '**/*', negated: false }] })
const candidates = scanner.scan()
const designSystem = buildDesignSystem(new Theme())
bench('parseCandidate', () => {
for (let candidate of candidates) {
Array.from(parseCandidate(candidate, designSystem))
}
describe('parsing', () => {
bench('parseCandidate', () => {
for (let candidate of candidates) {
Array.from(parseCandidate(candidate, designSystem))
}
})
})
describe('Candidate cloning', async () => {
let parsedCanddiates = candidates.flatMap((candidate) =>
Array.from(parseCandidate(candidate, designSystem)),
)
bench('structuredClone', () => {
for (let candidate of parsedCanddiates) {
structuredClone(candidate)
}
})
bench('cloneCandidate', () => {
for (let candidate of parsedCanddiates) {
cloneCandidate(candidate)
}
})
bench('cloneCandidate (spread)', () => {
for (let candidate of parsedCanddiates) {
cloneCandidateSpread(candidate)
}
})
})
function cloneCandidateSpread(candidate: Candidate): Candidate {
switch (candidate.kind) {
case 'arbitrary':
return {
...candidate,
modifier: candidate.modifier ? { ...candidate.modifier } : null,
variants: candidate.variants.map(cloneVariantSpread),
}
case 'static':
return { ...candidate, variants: candidate.variants.map(cloneVariantSpread) }
case 'functional':
return {
...candidate,
value: candidate.value ? { ...candidate.value } : null,
modifier: candidate.modifier ? { ...candidate.modifier } : null,
variants: candidate.variants.map(cloneVariantSpread),
}
default:
candidate satisfies never
throw new Error('Unknown candidate kind')
}
}
function cloneVariantSpread(variant: Variant): Variant {
switch (variant.kind) {
case 'arbitrary':
case 'static':
return { ...variant }
case 'functional':
return { ...variant, modifier: variant.modifier ? { ...variant.modifier } : null }
case 'compound':
return {
...variant,
variant: cloneVariantSpread(variant.variant),
modifier: variant.modifier ? { ...variant.modifier } : null,
}
default:
variant satisfies never
throw new Error('Unknown variant kind')
}
}

View file

@ -215,6 +215,102 @@ export type Candidate =
raw: string
}
export function cloneCandidate<T extends Candidate>(candidate: T): T {
switch (candidate.kind) {
case 'arbitrary':
return {
kind: candidate.kind,
property: candidate.property,
value: candidate.value,
modifier: candidate.modifier
? { kind: candidate.modifier.kind, value: candidate.modifier.value }
: null,
variants: candidate.variants.map(cloneVariant),
important: candidate.important,
raw: candidate.raw,
} satisfies Extract<Candidate, { kind: 'arbitrary' }> as T
case 'static':
return {
kind: candidate.kind,
root: candidate.root,
variants: candidate.variants.map(cloneVariant),
important: candidate.important,
raw: candidate.raw,
} satisfies Extract<Candidate, { kind: 'static' }> as T
case 'functional':
return {
kind: candidate.kind,
root: candidate.root,
value: candidate.value
? candidate.value.kind === 'arbitrary'
? {
kind: candidate.value.kind,
dataType: candidate.value.dataType,
value: candidate.value.value,
}
: {
kind: candidate.value.kind,
value: candidate.value.value,
fraction: candidate.value.fraction,
}
: null,
modifier: candidate.modifier
? { kind: candidate.modifier.kind, value: candidate.modifier.value }
: null,
variants: candidate.variants.map(cloneVariant),
important: candidate.important,
raw: candidate.raw,
} satisfies Extract<Candidate, { kind: 'functional' }> as T
default:
candidate satisfies never
throw new Error('Unknown candidate kind')
}
}
export function cloneVariant<T extends Variant>(variant: T): T {
switch (variant.kind) {
case 'arbitrary':
return {
kind: variant.kind,
selector: variant.selector,
relative: variant.relative,
} satisfies Extract<Variant, { kind: 'arbitrary' }> as T
case 'static':
return { kind: variant.kind, root: variant.root } satisfies Extract<
Variant,
{ kind: 'static' }
> as T
case 'functional':
return {
kind: variant.kind,
root: variant.root,
value: variant.value ? { kind: variant.value.kind, value: variant.value.value } : null,
modifier: variant.modifier
? { kind: variant.modifier.kind, value: variant.modifier.value }
: null,
} satisfies Extract<Variant, { kind: 'functional' }> as T
case 'compound':
return {
kind: variant.kind,
root: variant.root,
variant: cloneVariant(variant.variant),
modifier: variant.modifier
? { kind: variant.modifier.kind, value: variant.modifier.value }
: null,
} satisfies Extract<Variant, { kind: 'compound' }> as T
default:
variant satisfies never
throw new Error('Unknown variant kind')
}
}
export function* parseCandidate(input: string, designSystem: DesignSystem): Iterable<Candidate> {
// hover:focus:underline
// ^^^^^ ^^^^^^ -> Variants

View file

@ -1,5 +1,7 @@
import * as AttributeSelectorParser from './attribute-selector-parser'
import {
cloneCandidate,
cloneVariant,
printModifier,
type Candidate,
type CandidateModifier,
@ -107,7 +109,7 @@ const canonicalizeVariantCache = new DefaultMap((ds: DesignSystem) => {
for (let current of replacement.splice(0)) {
// A single variant can result in multiple variants, e.g.:
// `[&>[data-selected]]:flex` → `*:data-selected:flex`
let result = fn(ds, structuredClone(current))
let result = fn(ds, cloneVariant(current))
if (Array.isArray(result)) {
replacement.push(...result)
continue
@ -134,7 +136,7 @@ const UTILITY_CANONICALIZATIONS = [
const canonicalizeUtilityCache = new DefaultMap((ds: DesignSystem) => {
return new DefaultMap((rawCandidate: string): string => {
for (let readonlyCandidate of ds.parseCandidate(rawCandidate)) {
let replacement = structuredClone(readonlyCandidate) as Writable<typeof readonlyCandidate>
let replacement = cloneCandidate(readonlyCandidate) as Writable<typeof readonlyCandidate>
for (let fn of UTILITY_CANONICALIZATIONS) {
replacement = fn(ds, replacement)

View file

@ -153,7 +153,7 @@ export function keyPathToCssProperty(path: string[]) {
// The legacy container component config should not be included in the Theme
if (path[0] === 'container') return null
path = structuredClone(path)
path = path.slice()
if (path[0] === 'animation') path[0] = 'animate'
if (path[0] === 'aspectRatio') path[0] = 'aspect'

View file

@ -1,4 +1,4 @@
import { atRule, decl, type AstNode, type AtRule } from '../ast'
import { atRule, cloneAstNode, decl, type AstNode, type AtRule } from '../ast'
import type { DesignSystem } from '../design-system'
import { compareBreakpoints } from '../utils/compare-breakpoints'
import type { ResolvedConfig } from './config/types'
@ -16,7 +16,7 @@ export function registerContainerCompat(userConfig: ResolvedConfig, designSystem
return
}
designSystem.utilities.static('container', () => structuredClone(rules))
designSystem.utilities.static('container', () => rules.map(cloneAstNode))
}
export function buildCustomContainerUtilityRules(

View file

@ -1,6 +1,6 @@
import type { Features } from '..'
import { substituteAtApply } from '../apply'
import { atRule, decl, rule, walk, type AstNode } from '../ast'
import { atRule, cloneAstNode, decl, rule, walk, type AstNode } from '../ast'
import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate'
import { substituteFunctions } from '../css-functions'
import * as CSS from '../css-parser'
@ -329,7 +329,7 @@ export function buildPluginApi({
}
designSystem.utilities.static(className, (candidate) => {
let clonedAst = structuredClone(ast)
let clonedAst = ast.map(cloneAstNode)
replaceNestedClassNameReferences(clonedAst, className, candidate.raw)
featuresRef.current |= substituteAtApply(clonedAst, designSystem)
return clonedAst

View file

@ -1,6 +1,7 @@
import {
atRoot,
atRule,
cloneAstNode,
decl,
rule,
styleRule,
@ -6008,7 +6009,7 @@ export function createCssUtility(node: AtRule) {
})
designSystem.utilities.functional(name.slice(0, -2), (candidate) => {
let atRule = structuredClone(node)
let atRule = cloneAstNode(node)
let value = candidate.value
let modifier = candidate.modifier
@ -6176,7 +6177,7 @@ export function createCssUtility(node: AtRule) {
if (IS_VALID_STATIC_UTILITY_NAME.test(name)) {
return (designSystem: DesignSystem) => {
designSystem.utilities.static(name, () => structuredClone(node.nodes))
designSystem.utilities.static(name, () => node.nodes.map(cloneAstNode))
}
}

View file

@ -3,6 +3,7 @@ import {
WalkAction,
atRoot,
atRule,
cloneAstNode,
decl,
rule,
styleRule,
@ -100,7 +101,7 @@ export class Variants {
this.static(
name,
(r) => {
let body = structuredClone(ast)
let body = ast.map(cloneAstNode)
if (usesAtVariant) substituteAtVariant(body, designSystem)
substituteAtSlot(body, r.nodes)
r.nodes = body