Improve walk(…) performance (#15529)

This PR is a tiny improvement to the `walk(…)` implementations, not a
super big
deal but thought about something and was pleasently surprised that it
did have
an impact.

The idea is twofold:

1. Reduce array allocations while walking to build a `path` to the
current node. This re-uses the existing `path` array and pushes the
current node before the recursive call and pops it afterwards. This way
we don't need to allocate a new array for each recursive call. Testing
this on Tailwind UI means ~14k fewer allocations.
2. Instead of always calling `.splice(…)`, we can directly update a
single value in the array if we are replacing a node with another node.
   
Testing on the Tailwind UI codebase, this results in:

![image](https://github.com/user-attachments/assets/5a1c2102-1493-410f-b527-847fb4a75b31)

---------

Co-authored-by: Philipp Spiess <hello@philippspiess.com>
This commit is contained in:
Robin Malfait 2025-01-06 15:27:55 +01:00 • committed by GitHub
parent 8299d0486b
commit af3345189a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 71 additions and 24 deletions

View file

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix incorrectly named `bg-round` and `bg-space` utilities to `bg-repeat-round` to `bg-repeat-space` ([#15462](https://github.com/tailwindlabs/tailwindcss/pull/15462))
- Fix `inset-shadow-*` suggestions in IntelliSense ([#15471](https://github.com/tailwindlabs/tailwindcss/pull/15471))
- Only compile arbitrary values ending in `]` ([#15503](https://github.com/tailwindlabs/tailwindcss/pull/15503))
- Improve performance and memory usage ([#15529](https://github.com/tailwindlabs/tailwindcss/pull/15529))
### Changed
@ -781,3 +782,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [4.0.0-alpha.1] - 2024-03-06
- First 4.0.0-alpha.1 release

View file

@ -119,39 +119,49 @@ export function walk(
path: AstNode[]
},
) => void | WalkAction,
parentPath: AstNode[] = [],
path: AstNode[] = [],
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let path = [...parentPath, node]
let parent = parentPath.at(-1) ?? null
let parent = path[path.length - 1] ?? null
// We want context nodes to be transparent in walks. This means that
// whenever we encounter one, we immediately walk through its children and
// furthermore we also don't update the parent.
if (node.kind === 'context') {
if (
walk(node.nodes, visit, parentPath, { ...context, ...node.context }) === WalkAction.Stop
) {
if (walk(node.nodes, visit, path, { ...context, ...node.context }) === WalkAction.Stop) {
return WalkAction.Stop
}
continue
}
path.push(node)
let status =
visit(node, {
parent,
context,
path,
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
} else if (newNode.length === 1) {
ast[i] = newNode[0]
} else {
ast.splice(i, 1, ...newNode)
}
} else {
ast[i] = newNode
}
// We want to visit the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop
// will process this position (containing the replaced node) again.
i--
},
}) ?? WalkAction.Continue
path.pop()
// Stop the walk entirely
if (status === WalkAction.Stop) return WalkAction.Stop
@ -160,7 +170,11 @@ export function walk(
if (status === WalkAction.Skip) continue
if (node.kind === 'rule' || node.kind === 'at-rule') {
if (walk(node.nodes, visit, path, context) === WalkAction.Stop) {
path.push(node)
let result = walk(node.nodes, visit, path, context)
path.pop()
if (result === WalkAction.Stop) {
return WalkAction.Stop
}
}
@ -179,32 +193,45 @@ export function walkDepth(
replaceWith(newNode: AstNode[]): void
},
) => void,
parentPath: AstNode[] = [],
path: AstNode[] = [],
context: Record<string, string | boolean> = {},
) {
for (let i = 0; i < ast.length; i++) {
let node = ast[i]
let path = [...parentPath, node]
let parent = parentPath.at(-1) ?? null
let parent = path[path.length - 1] ?? null
if (node.kind === 'rule' || node.kind === 'at-rule') {
path.push(node)
walkDepth(node.nodes, visit, path, context)
path.pop()
} else if (node.kind === 'context') {
walkDepth(node.nodes, visit, parentPath, { ...context, ...node.context })
walkDepth(node.nodes, visit, path, { ...context, ...node.context })
continue
}
path.push(node)
visit(node, {
parent,
context,
path,
replaceWith(newNode) {
ast.splice(i, 1, ...newNode)
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
} else if (newNode.length === 1) {
ast[i] = newNode[0]
} else {
ast.splice(i, 1, ...newNode)
}
} else {
ast[i] = newNode
}
// Skip over the newly inserted nodes (being depth-first it doesn't make sense to visit them)
i += newNode.length - 1
},
})
path.pop()
}
}

View file

@ -11,7 +11,7 @@ export function deepMerge<T extends object>(
target: T,
sources: (Partial<T> | null | undefined)[],
customizer: (a: any, b: any, keypath: (keyof T)[]) => any,
parentPath: (keyof T)[] = [],
path: (keyof T)[] = [],
) {
type Key = keyof T
type Value = T[Key]
@ -22,21 +22,17 @@ export function deepMerge<T extends object>(
}
for (let k of Reflect.ownKeys(source) as Key[]) {
let currentParentPath = [...parentPath, k]
let merged = customizer(target[k], source[k], currentParentPath)
path.push(k)
let merged = customizer(target[k], source[k], path)
if (merged !== undefined) {
target[k] = merged
} else if (!isPlainObject(target[k]) || !isPlainObject(source[k])) {
target[k] = source[k] as Value
} else {
target[k] = deepMerge(
{},
[target[k], source[k]],
customizer,
currentParentPath as any,
) as Value
target[k] = deepMerge({}, [target[k], source[k]], customizer, path as any) as Value
}
path.pop()
}
}

View file

@ -96,7 +96,18 @@ export function walk(
visit(node, {
parent,
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
} else if (newNode.length === 1) {
ast[i] = newNode[0]
} else {
ast.splice(i, 1, ...newNode)
}
} else {
ast[i] = newNode
}
// We want to visit the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop
// will process this position (containing the replaced node) again.

View file

@ -67,7 +67,18 @@ export function walk(
visit(node, {
parent,
replaceWith(newNode) {
ast.splice(i, 1, ...(Array.isArray(newNode) ? newNode : [newNode]))
if (Array.isArray(newNode)) {
if (newNode.length === 0) {
ast.splice(i, 1)
} else if (newNode.length === 1) {
ast[i] = newNode[0]
} else {
ast.splice(i, 1, ...newNode)
}
} else {
ast[i] = newNode
}
// We want to visit the newly replaced node(s), which start at the
// current index (i). By decrementing the index here, the next loop
// will process this position (containing the replaced node) again.