Skip to content

Conversation

@RobinMalfait
Copy link
Member

@RobinMalfait RobinMalfait commented Oct 14, 2025

This PR generalizes the walk implementations we have. What's important here is that we currently have multiple walk implementations, one for the AST, one for the SelectorParser, one for the ValueParser.

Sometimes, we also need to go up the tree in a depth-first manner. For that, we have walkDepth implementations.

The funny thing is, all these implementations are very very similar, even the kinds of trees are very similar. They are just objects with nodes: [] as children.

So this PR introduces a generic walk function that can work on all of these trees.

There are also some situations where you need to go down and back up the tree. For this reason, we added an enter and exit phase:

walk(ast, {
  enter(node, ctx) {},
  exit(node, ctx) {},
})

This means that you don't need to walk(ast) and later walkDepth(ast) in case you wanted to do something after visiting all nodes.

The API of these walk functions also slightly changed to fix some problems we've had before. One is the replaceWith function. You could technically call it multiple times, but that doesn't make sense so instead you always have to return an explicit WalkAction. The possibilities are:

// The ones we already had
WalkAction.Continue // Continue walking as normal, the default behavior
WalkAction.Skip // Skip walking the `nodes` of the current node
WalkAction.Stop // Stop the entire walk

// The new ones
WalkAction.Replace(newNode) // Replace the current node, and continue walking the new node(s)
WalkAction.ReplaceSkip(newNode) // Replace the current node, but don't walk the new node(s)
WalkAction.ReplaceStop(newNode) // Replace the current node, but stop the entire walk

To make sure that we can walk in both directions, and to make sure we have proper control over when to walk which nodes, the walk function is implemented in an iterative manner using a stack instead of recursion.

This also means that a WalkAction.Stop or WalkAction.ReplaceStop will immediately stop the walk, without unwinding the entire call stack.

Some notes:

  • The CSS AST does have context nodes, for this we can build up the context lazily when we need it. I added a cssContext(ctx) that gives you an enhanced context including the context object that you can read information from.
  • The second argument of the walk function can still be a normal function, which is equivalent to { enter: fn }.

Let's also take a look at some numbers. With this new implementation, each walk is roughly ~1.3-1.5x faster than before. If you look at the memory usage (especially in Bun) we go from ~2.2GB peak memory usage, to ~300mb peak memory usage.

Some benchmarks on small and big trees (M1 Max):

image image

We also ran some benchmarks on @thecrypticace's M3 Max:

image

In node the memory difference isn't that big, but the performance itself is still better:

image

In summary:

  1. Single walk implementation for multiple use cases
  2. Support for enter and exit phases
  3. New WalkAction possibilities for better control
  4. Overall better performance
  5. ... and lower memory usage

Test plan

  1. All tests still pass (but had to adjust some of the APIs if walk was used inside tests).
  2. Added new tests for the walk implementation
  3. Ran local benchmarks to verify the performance improvements

Comment on lines 44 to 47
if (node.kind === 'context') {
walkContext = { ...walkContext, ...node.context }
return
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you read this commit by commit, this will get replaced in future commits!

expect(visited).toMatchInlineSnapshot(`
Set {
"@media ",
"<context>",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is added because the generic walk doesn't skip context nodes.

Comment on lines +12 to +13
walk(valueAst, {
exit(valueNode) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This diff looks big, but it's mainly re-indents

@RobinMalfait RobinMalfait marked this pull request as ready for review October 14, 2025 21:45
@RobinMalfait RobinMalfait requested a review from a team as a code owner October 14, 2025 21:45
Comment on lines +11 to +19
Continue: { kind: WalkKind.Continue } as const,
Skip: { kind: WalkKind.Skip } as const,
Stop: { kind: WalkKind.Stop } as const,
Replace: <T>(nodes: T | T[]) =>
({ kind: WalkKind.Replace, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const,
ReplaceSkip: <T>(nodes: T | T[]) =>
({ kind: WalkKind.ReplaceSkip, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const,
ReplaceStop: <T>(nodes: T | T[]) =>
({ kind: WalkKind.ReplaceStop, nodes: Array.isArray(nodes) ? nodes : [nodes] }) as const,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only reason these are uppercase is because they represent enum values. We don't have ADTs in JS sadly, so we work around it by having a function that returns a specific object.

All of them have a unique kind discriminant.

We already had Continue, Skip, Stop, and replaceWith. I then added Replace(…), ReplaceSkip(…) and ReplaceStop(…).

Open for suggestions for better names here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These names seem completely fine to me imo.

Like you could go with ReplaceAndSkip(…) or w/e if you wanted but I really don't think that is necessary. This perfectly readable as is.

I also dig the ADT-ish syntax here so also like the uppercase names 🤷‍♂️

While refactoring, I ran into issues because sometimes we destructure
`parent` which happens to be a "global" value that TypeScript is fine
with if it's undefined (tests would crash).

Using `ctx`, also helped to know where we are using all context related
values.

This will make future commits a bit more readable as well.

Might go back to a destructured pattern, but let's do this for now. In
future commits you will also see that often the `ctx` is just gone,
because instead of using `ctx.replaceWith()` becomes a `return …`
This is completely generic, the only requirement is that "parent" nodes
have `{ nodes: T[] }` as the signature.

It's a new iterative walk with minimal memory usage and performance
better than the recursive based walk function we had.

This also adds new functionality where we now can have an `enter` and
`exit` phase. The `exit` phase is like a depth-first where we visit leaf
nodes first.

If you have both, we only do a single walk down and up again instead of
you having to write 2 separate walks that traverse the entire tree.

Some API changes:

1. `replaceWith(…)` doesn't exist anymore, instead you return any of the
   `WalkAction`s. E.g.: `return WalkAction.Replace(…)`
2. `path` is not build while traversing, instead it's a function you can
   call to lazily compute it because in most cases we don't even need
   this functionality.

One benefit is that there is no call stack, so the moment you stop a
walk it's an instant return without unwinding the call stack.

All actions you can do are:

- `WalkAction.Continue`
- `WalkAction.Skip`
- `WalkAction.Stop`
- `WalkAction.Replace(…)`       — replace nodes and continue walking the new nodes
- `WalkAction.ReplaceSkip(…)`   — replace nodes and skip walking the new nodes
- `WalkAction.ReplaceStop(…)`   — replace nodes and stop the entire traversal
The CSS AST has `context` nodes that you can access while walking the
tree, where they build up information over time.

Additionally, when looking at the `path` or the `parent` all the
`context` nodes are hidden.

This adds a small wrapper for the context object when walking over
AstNode's and have to access the `.context`, `.parent` or `.path()`
1. Use single function with noop `enter` and `exit` functions instead of
   3 separate functions.
2. Instead of repeating some logic with sub-switches, just toggle the
   offset and let the loop go around.
3. Cleanup some comments
@RobinMalfait RobinMalfait merged commit acb27ef into main Oct 15, 2025
7 checks passed
@RobinMalfait RobinMalfait deleted the feat/generalize-walk branch October 15, 2025 19:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants