Skip to content

Scoped style with nested & selector breaks in Astro 6 when Vite uses lightningcss as CSS transformer #16524

@rklos

Description

@rklos

Astro Info

Astro                    v6.1.10
Vite                     v7.3.2
Node                     v24.14.0
System                   macOS (arm64)
Package Manager          npm
Output                   static
Adapter                  none
Integrations             none

If this issue only occurs in one browser, which browser is a problem?

No response

Describe the Bug

I have a scoped <style> block in an .astro file with a nested rule that uses &, like this:

.parent {
  display: flex;
  :where(& > :not(:last-child)) { margin-inline-end: 24px; }
}

In a default Astro 6 build this works fine. The browser receives the rule with the nesting still present, & evaluates against .parent[data-astro-cid-PARENT], and the inner :not(:last-child) matches direct children regardless of who rendered them.

Once I set vite.css.transformer: 'lightningcss' (which is what Tailwind v4 effectively pulls in for many projects), the rule that lands in the bundle becomes:

[data-astro-cid-PARENT]:where(.parent > :not(:last-child)) { margin-inline-end: 24px; }

That selector is not equivalent to the original. The leading [data-astro-cid-PARENT] is now a compound for the matched child, not for .parent. So the rule only matches if the child also carries data-astro-cid-PARENT. When the children are rendered by another .astro component (which is the whole point of scoped styles being scoped), they carry their own different cid, and the rule never matches.

Why this matters in practice

Tailwind v4's space-x-*, space-y-*, and divide-* utilities all expand to :where(& > :not(:last-child)) { ... }. Tailwind v4 uses lightningcss internally. So any Astro 6 project that uses these utilities (directly or via @apply) inside a scoped style block, where the targeted children come from a different .astro component, silently produces broken spacing in production. The CSS rule visibly exists in the bundle. It just cannot match. The user-side workaround is to swap space-x-*/space-y-* for gap-*, since gap does not use a descendant selector.

I hit this on a real project (header navigation lost spacing in production but worked in dev). It took a long time to figure out because the CSS rule looks present and correct at a glance.

How this differs from #15907

#15907 covers nested selectors without &. That was addressed by withastro/compiler#1153. The case here uses & explicitly, and the breakage is triggered by lightningcss flattening the nested rule before the scope id ends up where it belongs. PR #1153 does not cover this path.

Reproducer

Repo: https://github.com/rklos/astro-css-bug-repro

Two components, one page. Astro 6.1.10, Vite 7.3.2, @astrojs/compiler 3.0.1, lightningcss 1.32.0, Node 24.14.0, macOS arm64.

src/components/Parent.astro:

---
import Child from './Child.astro';
---
<div class="parent">
  <Child>One</Child>
  <Child>Two</Child>
  <Child>Three</Child>
</div>

<style>
  .parent {
    display: flex;
    :where(& > :not(:last-child)) {
      margin-inline-end: 24px;
    }
  }
</style>

src/components/Child.astro:

<a href="#"><slot /></a>

<style>
  /* this style block is what gives Child its own data-astro-cid-* */
  a { color: inherit; text-decoration: none; }
</style>

src/pages/index.astro just imports Parent and renders it inside <html><body>.

The astro.config.mjs in the repo exposes a matrix via VARIANT=<name> npm run build, so the table below is reproducible.

Variant matrix

Variant Vite config CSS for the nested rule Bug?
default Astro 6 defaults .parent[data-astro-cid-X]{display:flex;:where(&>:not(:last-child)){margin-inline-end:24px}} no
no-minify build.cssMinify: false nested form preserved no
esbuild build.cssMinify: 'esbuild' nested form preserved no
lightningcss-minify-only build.cssMinify: 'lightningcss' nested form preserved no
lightningcss-transform-only css.transformer: 'lightningcss' [data-astro-cid-X]:where(.parent>:not(:last-child)){...} YES
lightningcss-transform-no-minify transformer + cssMinify: false same broken form, unminified YES
lightningcss transformer + cssMinify: 'lightningcss' same broken form YES
where-strategy scopedStyleStrategy: 'where' nested form preserved no
class-strategy scopedStyleStrategy: 'class' nested form preserved no
lightningcss-where scopedStyleStrategy: 'where' + transformer :where(.astro-X):where(.parent>:not(:last-child)){...} (same shape) YES

The minimal trigger is vite.css.transformer: 'lightningcss'. The lightningcss minifier on its own does not reproduce it. The variants that disable minification confirm the broken form exists before any minify pass, so this is not a minifier bug.

What I think is happening

When vite.css.transformer: 'lightningcss' is on, lightningcss is processing the per-component CSS at a point where the cid has not yet been merged into the .parent compound. So it sees .parent { :where(& > :not(:last-child)) { ... } } and (correctly, for this input) flattens to :where(.parent > :not(:last-child)) { ... }. After that, scope injection adds [data-astro-cid-X]. But by now .parent is no longer a top level compound, it is buried inside :where(...). The injector falls back to prepending [data-astro-cid-X] as a fresh leading compound, which lands it on the matched element rather than on .parent.

In every other variant, scope is on .parent before any flattener runs, so either nothing flattens (and & survives to the browser, which handles it correctly) or lightningcss flattens with the cid already attached to .parent (and the result is correct).

This shape of bug is similar to what compiler#1153 fixed for the non & case, but the fix did not extend to selectors where the parent compound has already been wrapped in :where(...) by an upstream transform.

Reproduce locally

git clone https://github.com/rklos/astro-css-bug-repro.git
cd astro-css-bug-repro
npm install

# default, works
VARIANT=default npm run build
grep -oE '<style[^>]*>.*</style>' dist/index.html

# minimal trigger, broken
VARIANT=lightningcss-transform-only npm run build
grep -oE '<style[^>]*>.*</style>' dist/index.html

# pure lightningcss control, correct output
node scripts/lightningcss-pure-test.mjs

The first build prints a <style> block with the nested rule still containing &. The second prints the broken flattened form. The third shows lightningcss producing the correct flatten on its own.

References

  • Related closed issue: withastro/astro#15907
  • Related merged compiler PR: withastro/compiler#1153
  • Tailwind v4 space-x-*, space-y-*, and divide-* are the most common real world way to hit this, since Tailwind v4 ships with lightningcss in the loop.

What's the expected result?

The scoped nested rule should still match the targeted children in production, the same way it does in dev and in the default build. Concretely, the CSS that lands in the bundle should keep the scope id attached to .parent, not promoted to a leading compound that constrains the matched child.

For the input:

.parent {
  display: flex;
  :where(& > :not(:last-child)) { margin-inline-end: 24px; }
}

the produced CSS should be either of these (both are correct):

Nested form preserved (what default and esbuild builds already do):

.parent[data-astro-cid-PARENT] {
  display: flex;
  :where(& > :not(:last-child)) { margin-inline-end: 24px; }
}

Or, if the nested rule is flattened, the cid stays on .parent inside the :where(...):

.parent[data-astro-cid-PARENT] { display: flex; }
:where(.parent[data-astro-cid-PARENT] > :not(:last-child)) { margin-inline-end: 24px; }

What the bundle currently contains when vite.css.transformer: 'lightningcss' is on:

[data-astro-cid-PARENT]:where(.parent > :not(:last-child)) { margin-inline-end: 24px; }

This is the form that should not be produced, because the scope id ends up constraining the matched child rather than the ancestor.

Possible directions for a fix:

  1. Teach the Astro scope injector to walk into a leading :where(...) (or any pseudo wrapper whose argument is a selector list) and attach the cid to the inner .parent compound when one exists, instead of prepending it as a new leading compound.
  2. Reorder the pipeline so scope injection happens before the lightningcss transformer runs on .astro style blocks. Pure lightningcss given the already scoped CSS produces the correct flatten on its own (verified in scripts/lightningcss-pure-test.mjs).
  3. At minimum, document the interaction between vite.css.transformer: 'lightningcss' and nested & selectors in scoped styles, and recommend using the lightningcss minifier instead of the transformer for projects that hit this.

Link to Minimal Reproducible Example

https://github.com/rklos/astro-css-bug-repro

Participation

  • I am willing to submit a pull request for this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    - P4: importantViolate documented behavior or significantly impacts performance (priority)pkg: astroRelated to the core `astro` package (scope)

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions