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:
- 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.
- 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).
- 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
Astro Info
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.astrofile with a nested rule that uses&, like this: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: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 carriesdata-astro-cid-PARENT. When the children are rendered by another.astrocomponent (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-*, anddivide-*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.astrocomponent, silently produces broken spacing in production. The CSS rule visibly exists in the bundle. It just cannot match. The user-side workaround is to swapspace-x-*/space-y-*forgap-*, sincegapdoes 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:src/components/Child.astro:src/pages/index.astrojust importsParentand renders it inside<html><body>.The
astro.config.mjsin the repo exposes a matrix viaVARIANT=<name> npm run build, so the table below is reproducible.Variant matrix
default.parent[data-astro-cid-X]{display:flex;:where(&>:not(:last-child)){margin-inline-end:24px}}no-minifybuild.cssMinify: falseesbuildbuild.cssMinify: 'esbuild'lightningcss-minify-onlybuild.cssMinify: 'lightningcss'lightningcss-transform-onlycss.transformer: 'lightningcss'[data-astro-cid-X]:where(.parent>:not(:last-child)){...}lightningcss-transform-no-minifycssMinify: falselightningcsscssMinify: 'lightningcss'where-strategyscopedStyleStrategy: 'where'class-strategyscopedStyleStrategy: 'class'lightningcss-wherescopedStyleStrategy: 'where'+ transformer:where(.astro-X):where(.parent>:not(:last-child)){...}(same shape)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.parentcompound. 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.parentis 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
.parentbefore 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
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
space-x-*,space-y-*, anddivide-*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:
the produced CSS should be either of these (both are correct):
Nested form preserved (what default and esbuild builds already do):
Or, if the nested rule is flattened, the cid stays on
.parentinside the:where(...):What the bundle currently contains when
vite.css.transformer: 'lightningcss'is on: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:
:where(...)(or any pseudo wrapper whose argument is a selector list) and attach the cid to the inner.parentcompound when one exists, instead of prepending it as a new leading compound..astrostyle blocks. Pure lightningcss given the already scoped CSS produces the correct flatten on its own (verified inscripts/lightningcss-pure-test.mjs).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