css: bound selector expansion through nesting-holding at-rules (#31482)
## What
A deeply-nested CSS stylesheet that interleaves multi-selector rules
with a nesting-holding at-rule (e.g. `@starting-style`) hangs for 20+
seconds and produces gigabytes of output when compiled for browser
targets that lack CSS nesting. A 599-byte, fully-printable input
reproduces it.
## Repro
```
BUN_GARBAGE_COLLECTOR_LEVEL=0 BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING=1 bun -e 'const I = Buffer.from(Bun.gunzipSync(Buffer.from("H4sIAAAAAAACA4tOy8+3VUpKLFKK1VHQA9IK1VzRo2JcDsUliUUlmXnpusUllTmpZIsM5zBSgILk/Jz8IiuFopzUtBKISC0AoEiSPlcCAAA=", "base64"))).toString("latin1"); require("bun:internal-for-testing").cssInternals.prefixTest(I, "", { safari: 11 << 16, firefox: 60 << 16, chrome: 50 << 16 })'
```
The input decodes to 14 nested `[foo="bar"], .bar` rules, then 4
`@starting-style` blocks, then 11 more nested `[foo="bar"], .bar` rules.
## Cause
The minifier already bounds this class of blowup:
`MAX_SELECTOR_EXPANSION` (`src/css/rules/mod.rs`) caps how many selector
copies compiling nesting away for the targets will produce, reporting
`selector_expansion_limit_exceeded` instead of expanding without bound.
The cap's running multiplier is threaded down through
`StyleRule::minify` → `CssRuleList::minify` as it recurses into nested
rules.
But the minify arms for `@starting-style`, `@container`, `@scope`,
`@-moz-document`, `@nest` and `@supports` never recursed into their
nested rules (the first five were no-op stubs; `@supports` called a
`SupportsRule::minify` that was itself a no-op). So any style rules
nested *behind* one of these at-rules were never visited by the minifier
and never counted against the cap.
At print time, the serializer still resolves the full parent-selector
chain through the at-rule, so the cartesian `&`-expansion runs across
the full depth unbounded. The printer's own per-prelude
`MAX_NESTING_EXPANSIONS` budget resets per prelude, so it bounds a
single prelude's `&` count but not the exponential *number* of preludes.
## Fix
Recurse into the nested rules of these at-rules during minify, mirroring
the existing `@layer-block` / `@media` arms, so the existing cap sees
the full nesting depth regardless of which at-rules are interleaved. The
runaway input now errors in milliseconds.
- **`@nest`** wraps a single style rule whose own selectors form a
nesting level. Its selectors are charged against the cap and its nested
rules recursed into — but its *own declarations are left verbatim*. The
selector-expansion charge/bump was factored out of `StyleRule::minify`
into `StyleRule::{charge_selector_expansion, minify_nested_rules}` and
reused, so `@nest` can account for its selectors without running
`StyleRule::minify` (which would feed the declarations through the
property handlers, consume logical properties into the handler context
the `@nest` port doesn't yet drain, and silently drop them).
- **`@scope`** clears the nesting context at print time, but compiling
nesting away still duplicates the whole `@scope` block once per
enclosing selector combination, so its output is exponential in the
outer depth and the cap must still count the outer levels.
This does not port the at-rule-specific minify logic (condition
merging/dedup) that remains a separate TODO — only the nested-rule
recursion that is the root cause of the unbounded expansion.
## ⚠️ Output-behavior change worth a look
Beyond the hang fix, recursing into these arms means declarations nested
inside `@starting-style`, `@container`, `@scope`, `@-moz-document` and
`@supports` now flow through the normal minification path (property
handlers, vendor-prefix lowering, adjacent-rule merging, dedup) where
the no-op stubs previously left them verbatim. This is a real change to
minifier *output* for valid stylesheets (e.g. duplicate declarations
inside `@starting-style` now collapse). The existing CSS suite (1087)
and bundler CSS tests (166) all pass, so no regression surfaced, but
flagging it for a maintainer's awareness. `@nest` is the exception — its
declarations stay verbatim (see above).
## Verification
- The repro now fails fast with `Nested CSS rules expand to more than
65536 selectors ...` (was: 20s+ timeout, CPU pegged).
- `css.test.ts`: 1087 pass, 0 fail. `test/bundler/css/`: 166 pass, 0
fail.
- `test/js/bun/css/nested-selector-list-expansion.test.ts` gains
parameterized coverage for all four context-preserving at-rules
(`@starting-style`/`@supports`/`@container`/`@-moz-document`), a `@nest`
test asserting its declaration survives verbatim with no sibling leak,
and `@scope` carry-through bounding. Confirmed the file **fails on the
unfixed build** (in-process minify/prefix produce MB of output without
erroring; `bun build` is SIGKILLed after hanging) and **passes with the
fix**. R
robobun committed
d632fc5b29cf90515e159983311cfc547173a87b
Parent: 63d5cd4
Committed by GitHub <noreply@github.com>
on 5/28/2026, 8:45:00 PM