Skip to content

Handle CSS nesting natively#20124

Merged
RobinMalfait merged 28 commits into
mainfrom
feat/handle-nesting
Jul 7, 2026
Merged

Handle CSS nesting natively#20124
RobinMalfait merged 28 commits into
mainfrom
feat/handle-nesting

Conversation

@RobinMalfait

@RobinMalfait RobinMalfait commented May 28, 2026

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. (Caveat: there are exceptions for body-less rules such as @layer foo; or `@charset "UTF-8";)

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. (Caveat: there are exceptions, in case of @font-family { … } where we don't want to merge them)

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. Once the implementation was in place, and all the tests were passing, then I deleted the oracle implementation. That way we don't have to keep it in sync all the time.

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, but only if they were introduced by the nesting logic. If you wrote :is(…) in your CSS, we won't touch it.

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 with the same specificity.

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 (<15ms):

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

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

One interesting thing to notice is that in big projects, this could add 10ms to the build, but Lightning CSS would then take less time to process, which results in a no-op with better output.

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. This way, a sibling rule will also get the already-prepared parent 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 but separate 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 2 times, most recently from d7aaac0 to 9e82582 Compare May 29, 2026 20:30
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.
@RobinMalfait RobinMalfait force-pushed the feat/handle-nesting branch 3 times, most recently from 402c33e to 2efc0c1 Compare July 6, 2026 21:34
fix crash when using `@layer x; @layer x {…}`

We were reading from `lastNode` that could be empty because of the
body-less `@layer x`

ensure we don't loose information when merging `@font-face`

`@font-face` does't have params, but the body can be different. We
should _not_ be merging these otherwise we might loose information.

do not omit `*` when it's a namespace

scope selector parser cache to `handleNesting`
@RobinMalfait RobinMalfait force-pushed the feat/handle-nesting branch from 2efc0c1 to aa89e73 Compare July 7, 2026 13:05
@RobinMalfait RobinMalfait marked this pull request as ready for review July 7, 2026 14:03
@RobinMalfait RobinMalfait requested a review from a team as a code owner July 7, 2026 14:03
@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Reviews (4): Last reviewed commit: "swap checks, only compute `path` when ab..." | Re-trigger Greptile

Comment thread packages/tailwindcss/src/selector-parser.ts
@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 44c2dcdb-f498-46cf-822b-d85bdf48776b

📥 Commits

Reviewing files that changed from the base of the PR and between e2d3139 and 9107de5.

📒 Files selected for processing (1)
  • packages/tailwindcss/src/ast.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/tailwindcss/src/ast.ts

Walkthrough

Changes

This PR adds handleNesting to flatten nested CSS in the AST pipeline and updates selector parsing for :nth-child/:nth-last-child of clauses, non-ASCII attribute names, and new selector helper exports. Variants now validate generated aria-* and data-* selectors, addUtilities skips additional selector functions, and extractUsedVariables uses a cache. Multiple tests and snapshots were updated across integrations and core packages, and the changelog was amended.

Related PRs: None identified.

Suggested labels: area: core, area: tests, needs-changeset

Suggested reviewers: thecrypticace, RobinMalfait

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: native handling of CSS nesting.
Description check ✅ Passed The description is directly related to the changeset and accurately explains the nesting pass and related optimizations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/tailwindcss/src/utils/variables.ts (1)

3-23: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Module-level cache never evicts and returns a shared mutable array.

DefaultMap has no eviction (confirmed: it's a plain Map subclass that permanently stores factory results), and this cache is module-scoped rather than scoped per-compile like the analogous parseSelectorCache in ast.ts's handleNesting. In practice this is bounded today since extractUsedVariables is only invoked for @theme variable declaration values (a small, stable set per project), so the growth risk is low. Also, .get(raw) returns the same array instance to every caller — currently safe since callers only iterate read-only, but a future caller that mutates the returned array would silently corrupt the cache for all other callers.

Consider scoping the cache per-optimizeAst invocation (matching the parseSelectorCache pattern) or returning a defensive copy, to avoid unbounded growth in long-running watch/dev-server processes and to guard against future mutation bugs.

♻️ Optional: return a copy instead of the cached reference
 export function extractUsedVariables(raw: string): string[] {
-  return extractUsedVariablesCache.get(raw)
+  return extractUsedVariablesCache.get(raw).slice()
 }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9be5ee53-8fa8-4cab-a86f-d287a5658cf5

📥 Commits

Reviewing files that changed from the base of the PR and between 9b0e8af and aa89e73.

📒 Files selected for processing (18)
  • integrations/cli/plugins.test.ts
  • integrations/vite/index.test.ts
  • integrations/vite/resolvers.test.ts
  • packages/tailwindcss/src/ast.test.ts
  • packages/tailwindcss/src/ast.ts
  • packages/tailwindcss/src/attribute-selector-parser.test.ts
  • packages/tailwindcss/src/attribute-selector-parser.ts
  • packages/tailwindcss/src/compat/config.test.ts
  • packages/tailwindcss/src/compat/plugin-api.test.ts
  • packages/tailwindcss/src/compat/plugin-api.ts
  • packages/tailwindcss/src/intellisense.test.ts
  • packages/tailwindcss/src/selector-parser.test.ts
  • packages/tailwindcss/src/selector-parser.ts
  • packages/tailwindcss/src/source-maps/source-map.test.ts
  • packages/tailwindcss/src/utilities.test.ts
  • packages/tailwindcss/src/utils/variables.ts
  • packages/tailwindcss/src/variants.test.ts
  • packages/tailwindcss/src/variants.ts

handle non-ascii characters during attribute parsing
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.
We are not too strict about the syntax, but the grammar looks like:
```
:nth-child([ <An+B> | even | odd ] [of <complex-selector-list>]?) {
  /* ... */
}
```

Which means that you could write `:nth-child(2 of &)` and we have to
repalce the `&` with the parent selector.

parse out selector list in `:nth-child` and `:nth-last-child`
@RobinMalfait RobinMalfait force-pushed the feat/handle-nesting branch from aa89e73 to dc1758a Compare July 7, 2026 16:07
We are checking wether the to-be-replaced ast is a complex selector or
not. If it is, only then do we care about the type of the grand parent
selector.
@RobinMalfait RobinMalfait force-pushed the feat/handle-nesting branch from e2d3139 to 9107de5 Compare July 7, 2026 16:18
@RobinMalfait RobinMalfait merged commit 5835691 into main Jul 7, 2026
10 checks passed
@RobinMalfait RobinMalfait deleted the feat/handle-nesting branch July 7, 2026 16: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.

1 participant