css: don't duplicate vendor-prefixed nested rules per ancestor prefix pass#31270
css: don't duplicate vendor-prefixed nested rules per ancestor prefix pass#31270robobun wants to merge 3 commits into
Conversation
… pass When CSS nesting is compiled away for older targets, a style rule whose selector needs vendor prefixes is serialized once per prefix, and each pass re-serialized all of its nested rules. Nested rules that carry their own vendor prefixes override the printer's vendor prefix, so those re-serializations were exact duplicates, doubling the output for every nesting level. A few kilobytes of nested prefixed selectors (e.g. :fullscreen) made the printer allocate gigabytes. Nested style rules with their own vendor prefixes are now only emitted during the final prefix pass of their ancestor, so output stays linear in nesting depth. Rules without their own prefixes still expand once per ancestor pass, since their output depends on the current prefix.
|
Warning Review limit reached
Your plan currently allows 2 reviews/hour. Refill in 21 minutes and 45 seconds. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more review capacity refills, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than trial, open-source, and free plans. In all cases, review capacity refills continuously over time. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Updated 5:05 AM PT - May 23rd, 2026
❌ @robobun, your commit 7c2907b has some failures in 🧪 To try this PR locally: bunx bun-pr 31270That installs a local version of the PR into your bun-31270 --bun |
A non-final vendor prefix pass can now emit nothing when the rule has no declarations and all of its nested rules are deferred to the final pass; don't leave a dangling blank-line separator in pretty output for it. Also drop the explicit test-level timeout from the new regression test and pin the non-minified output.
…rred When a rule has its own declarations and all of its nested rules are deferred to the final vendor prefix pass, the non-final pass wrote the separator between the declaration block and the nested rules with nothing following it. Check whether any nested rule will be emitted in the current pass before writing that separator, and share the deferral predicate with the rule-list printer.
|
@robobun compare with lightningcss |
|
Compared against lightningcss 1.32.0 (npm, same inputs, same targets). Short version: before this PR Bun's output here is byte-for-byte identical to lightningcss — including the exponential duplication — and lightningcss itself OOMs on the same 4.8 KB fuzzer input. The only thing this PR changes is that byte-identical duplicate rules are emitted once instead of once per ancestor prefix pass; every rule we emit is still a rule lightningcss emits. Growth of minified output for
lightningcss/Bun-before double per level (each prefixed ancestor re-serializes all nested rules per prefix pass); after the PR it's +33 B per level. Depth 2, what the duplication looks like — lightningcss (and Bun before): :-webkit-full-screen :-webkit-full-screen{color:red}:fullscreen :fullscreen{color:red}:-webkit-full-screen :-webkit-full-screen{color:red}:fullscreen :fullscreen{color:red}Bun after (the same two rules, once): :-webkit-full-screen :-webkit-full-screen{color:red}:fullscreen :fullscreen{color:red}When the parent has its own declarations ( /* lightningcss / Bun before */
:-webkit-full-screen{color:green}:-webkit-full-screen :-webkit-full-screen{color:red}:fullscreen :fullscreen{color:red}:fullscreen{color:green}:-webkit-full-screen :-webkit-full-screen{color:red}:fullscreen :fullscreen{color:red}
/* Bun after */
:-webkit-full-screen{color:green}:fullscreen{color:green}:-webkit-full-screen :-webkit-full-screen{color:red}:fullscreen :fullscreen{color:red}Everything else stays byte-identical to lightningcss. The dedup only applies when nesting is being compiled away and both the ancestor and the nested rule need vendor prefixes. Cases with only one of the two are unchanged, e.g. For the record, Bun 1.3.14 (the Zig CSS implementation) produces the same byte counts as lightningcss on all of the above, so the blowup wasn't introduced by the Rust port — it's faithful lightningcss behavior that both ports inherited; current lightningcss still does it. |
|
CI status on 7c2907b (build #57254): 74 checks pass — every lane that exercises this change is green on every platform: linux x64/aarch64 (incl. the ASAN test lane), alpine/debian/ubuntu, windows x64/aarch64, darwin-14-x64, darwin-26-aarch64 (passed on retry), all build jobs, format/clippy/lint. The single remaining red lane is |
|
The fuzzer also hit this same blowup as a hang signature ( Verified this PR also resolves that signature by cherry-picking the three commits onto current
For comparison I pushed a smaller alternative take on the same root cause to |
What does this PR do?
Fixes an OOM found by fuzzing in the CSS printer: a ~4.8 KB stylesheet of deeply nested
:fullscreenblocks makes re-serialization allocate multiple gigabytes when browser targets are set (including the bundler's default--target=browsertargets).where
app.cssis ~28 levels of nested:fullscreen { ... }rules (fuzzer signatureoom:css:…serialize_selector|…serialize_nesting|…serialize_selector|…serialize_selector_list|…StyleRule::to_css_base).Cause
When CSS nesting is compiled away for older targets,
StyleRule::to_cssserializes the rule once per vendor prefix (:fullscreen→:-webkit-full-screen+:fullscreenfor e.g. Safari 14), and each prefix pass re-serializes all nested rules. A nested rule that has its own vendor prefixes immediately overridesdest.vendor_prefixwith its own prefix loop, so its output is byte-for-byte identical in every ancestor pass — each nesting level doubles the output. At 28 levels that's 2²⁷ copies of the leaf rules → OOM. (The re-serialized copies are exact duplicates; with one prefixed selector per level only two distinct rules ever exist.)Fix
Track the vendor-prefix pass in
StyleRule::to_cssand, while re-serializing nested rules for a non-final pass, skip nested style rules that carry their own vendor prefixes (Printer.skip_prefixed_nested_rules). They are emitted exactly once, in the ancestor's final pass. Nested rules without their own prefixes still print in every pass, because their&expansion depends on the current prefix.Output is semantically unchanged — the same set of rules is emitted, just without the exact duplicates — and grows linearly with nesting depth instead of exponentially.
Verification
test/js/bun/css/nested-vendor-prefix-duplication.test.ts:bun build --target=browser --minifyon the nested input produces a small fileminifyTest(input, "", {safari: 8<<16})now returns 1,892 bytes (previously aborted >4 GB); the no-targets repro output is byte-identical before/after.test/js/bun/css/(2018 tests) andtest/bundler/css/+test/bundler/esbuild/css.test.ts(219 tests) pass with the fix.