Skip to content

Handle CSS nesting natively#20124

Draft
RobinMalfait wants to merge 20 commits into
mainfrom
feat/handle-nesting
Draft

Handle CSS nesting natively#20124
RobinMalfait wants to merge 20 commits into
mainfrom
feat/handle-nesting

Conversation

@RobinMalfait
Copy link
Copy Markdown
Member

This PR introduces a new feature where we will be handling the CSS nesting ourselves.

We currently still rely on Lightning CSS in most places. But there are situations where we don't use Lightning CSS out of the box:

  1. During development, typically optimization/minification isn't setup
  2. In places where it isn't as easy to run Lightning CSS such as in @tailwindcss/browser or in Tailwind Play.

We handle CSS nesting in a single pass over the AST by tracking some information as we go. It's not the most complex code, but there are some tricky parts to make this happen in an efficient way, especially for the few additional optimizations we handle.

While going over the AST, we will only emit CSS the moment we see declarations or comments. This also means that this has a fun side effect of removing CSS that ends up with empty nodes automatically.

This also allowed us to do some cleanup in optimizeAst that tried to do this as well, but now this will be handled by the code that handles nesting automatically. Which is preferred because the version in optimizeAst mutated the AST.

This also contains some optimizations where we merge adjacent at-rules (with the same name / params), and adjacent rules with the same selector, and get rid of declarations that are duplicated in a node.

To ensure that this implementation is correct, I also added an oracle implementation in the tests. This implementation does multiple passes over the AST, because it does each step one by one, with minimal code. Each step contains comments with examples to see what's happening in that step. We then test the optimized version against this.

While handling the nesting, we have to make sure that & exists and if we replace it with a parent selector that we do use :is(…) semantics. This means that:

.foo {
  &:hover {
    color: red;
  }
}

Becomes:

:is(.foo):hover {
  color: red;
}

We then also make sure that we optimize the selector by removing the unnecessary :is(…) wrappers. Sometimes we flip parts of the selector around to further optimize. E.g: :is(.foo, .bar):is(ul) cannot just become :is(.foo, .bar)ul, that would be invalid. So this becomes ul:is(.foo, .bar) instead.

If you look at the commits, the first thing we did is remove the optimization step from Lightning CSS in the tests. Then we enabled our CSS nesting handling code. This allows us to see the effect of the changes we are making. At the end, we re-enabled Lightning CSS.

For now, this PR will be a step that happens before Lightning CSS is executed, while still using Lightning CSS. But now this step will also always happen in places where we don't use Lightning CSS at all.

This should not result in any breaking changes. It could result in changed CSS output in environments where Lightning CSS isn't used. In environments where it is being used, then there could be some differences related to some selectors but they should result in the same behavior.

While testing things, I noticed that there are some missed opportunities for performance related to how we extract variables from declaration values. I want to tackle optimizeAst in future PRs to make it simpler, more performant, and maybe even merge it with the CSS nesting handling.

As part of testing this, I tested it against the tailwindcss.com codebase which contains a lot of CSS (807.67 KB, 18 174 AST nodes) because almost every utility is being used in examples.

The oracle implementation is rather slow:

[131.59ms]   ↳ oracle (step by step)
[129.23ms]     ↳ handleNesting(…)
[  2.32ms]     ↳ toCss(…)

But the final code is much faster (<10ms):

[ 10.11ms]   ↳ hand written (single pass)
[  8.40ms]     ↳ handleNesting(…)
[  1.67ms]     ↳ toCss(…)

In contrast, Lightning CSS takes: [ 37.48ms] Optimized by Lightning CSS

One thing to keep in mind here is that Lightning CSS does more things, such as normalizing values, handling vendor prefixes, CSS nesting, etc.

Some notes on how the algorithm works:

The basic idea

When you have CSS that looks this:

.foo {
  .bar {
    color: red;
  }
}

Then the AST looks like this:

[
  {
    kind: 'rule',
    selector: '.foo',
    nodes: [
      {
        kind: 'rule',
        selector: '.bar',
        nodes: [
          {
            kind: 'declaration',
            property: 'color',
            value: 'red',
            important: false
          }
        ]
      }
    ]
  }
]

When we walk this tree, and we encounter a rule, then we will track the selector on a stack. When we are done walking over the rule, then we will pop the selector from the stack. This means that the top-most selector on the stack will always be the parent selector.

let selectorStack = []

walk(ast, {
  enter(node) {
    selectorStack.push(node.selector)
  },
  exit(node) {
    selectorStack.pop()
  },
})

The moment we encounter a rule, and if a previous rule was seen, then we push the selector of the rule onto the stack, but in a way that the & is already replaced by the selector.

The simple version looks like this:

walk(ast, {
  enter(node) {
    // In the real code we properly handle `&` replacement, and make sure that
    // parent selector is prepended if there is no `&` used in the selector of the
    // node.
    let selector =
      selectorStack.length > 0
        // At this point, we don't optimize anything related to the selector yet
        ? node.selector.replaceAll('&', `:is(${selectorStack.at(-1)})`)
        : node.selector

    selectorStack.push(selector)
  },
  exit(node) {
    selectorStack.pop()
  },
})

So far we aren't doing much yet, but the interesting part is when we encounter a declaration (or a comment). The moment we see any of those, then will we emit a node with the information from the selectorStack.

We then also track the last node's nodes we created such that we can push more declarations into it as a shortcut.

let result: AstNode[] = []
let nodes: AstNode[] | null = null
walk(ast, {
  enter(node) {
    if (node.kind === 'declaration') {
      // `nodes` is available, nothing special to do
      if (nodes) {
        nodes.push(node)
        return
      }

      // Track new nodes
      let nodes = [node]

      // Create a new node with a reference to `nodes` for future declarations
      let newNode = rule(selectorStack.at(-1), nodes)
      result.push(newNode)
    }
  },
})

The last important part is that whenever we see a new rule, then we have to reset that nodes tracking variable such that we can create a fresh node the next time we see a declaration.

For the at-rules, something similar happens but they are tracked in a similar stack. The idea there is that we can then wrap those at-rules around the newNode we create. That way the at-rules naturally float to the top.

I can keep going here, but I think if you're interested in this, then you could go over the commits in this PR, or you can look at the ast.ts implementation directly to see what's going on.

Test plan

  1. Existing tests should pass
  2. New tests have been added to test the flattening of nested CSS

@RobinMalfait RobinMalfait force-pushed the feat/handle-nesting branch from bfc4058 to d7aaac0 Compare May 28, 2026 19:03
Use the `pretty(…)` helper for the AST related tests wherever we didn't
do that yet.
This is temporary and on purpose. Once we implement nesting, we can see
the impact on the tests.

Once we are happy with that, we can add the optimizations from Lightning
CSS again and if everything goes well, we should have barely any CSS
changes in the final diff (at least related to CSS nesting).
Will be undone once Lightning CSS is enabled again
Will be re-enabled once Lightning CSS is enabled again
These tests don't really test AST specific things. They really test how
`walk` works and what happens when you use certain `WalkAction`
commands...
This part of the optimize AST step is covered automatically by the
custom nesting handling.
If you are using `@variant` in your CSS without any rules, then we could
potentially introduce invalid CSS. We start from a rule with a `&`
selector, but if we didn't have a selector in the first place then we
would introduce a selector which is incorrect.

This fix tracks the nodes of the intermediate node if that's the case.
We were incorrectly mapping the source locations of rules to that of the
used declaration which was wrong.
@RobinMalfait RobinMalfait force-pushed the feat/handle-nesting branch from d7aaac0 to 9e82582 Compare May 29, 2026 20:30
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.

1 participant