Skip to content

css: split target-incompatible selectors out of a rule in linear time - #42243

Open
robobun wants to merge 2 commits into
mainfrom
robobun/405cb0f0/css-selector-split-linear
Open

robobun wants to merge 2 commits into
mainfrom
robobun/405cb0f0/css-selector-split-linear

Conversation

@robobun

@robobun robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A nested rule whose selector list has n selectors is O(n^2) to minify for the default browser targets. A 120 KB file (.a,.b{ .c0,…,.c16383 {color:red} }) takes 6 s in Bun.build. The same list at the top level takes 38 ms for 65k selectors (60% of samples in memmove called from minify_style_arm).
  • When the selectors are not all target-compatible and cannot be wrapped in :is(), minify_style_arm (src/css/rules/mod.rs) moved the incompatible ones out with ordered_remove(i) (line 698) and drained two follow-up lists with ordered_remove(0) (lines 785, 882). Each call shifts the rest of the list.

Fix

  • Partition the selector list in one pass, and consume incompatible and incompatible_rules by value.
  • Order is preserved in all three places, so the output is byte-identical.
  • Verified: test/js/bun/css/nested-selector-list-split-hang.test.ts (time ratio of a 50k-selector nested rule against the same list at the top level: 2x to 3x with the fix, 21x on debug main, 2600x on 1.4.2, plus an order check for a mixed list). Also test/js/bun/css/css.test.ts, the other nested-* tests, test/bundler/esbuild/css.test.ts.

Background

  • If one selector in a list is invalid for a browser, that browser drops the whole rule. So the minifier wraps a list with unsupported selectors in :is(), or, when :is() is not available, splits the unsupported selectors into one rule each.
  • A nested rule's selectors carry an implicit &. For targets without native nesting every one of them counts as unsupported, so the split runs over the whole list.
  • The default bun build targets predate both nesting and :is().
Notes
  • Release timings for the ledger script (Bun.build, nested / flat): n = 2048 / 4096 / 8192 / 16384 was 62 / 331 / 965 / 4104 ms nested before, 5 / 9 / 10 / 20 ms after; flat is 2 to 10 ms either way.
  • css: keep incompatible selectors that fail the same way in one rule #40464 changes which incompatible selectors share a rule and touches the same loop. This change keeps the current output and only removes the quadratic list handling, so the two are independent apart from the textual conflict.

[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/css/nested-selector-list-split-hang.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/184] fetch picohttpparser
[picohttpparser] fetching h2o/picohttpparser@066d2b1e
[picohttpparser] applying strict-chunk-size.patch
[picohttpparser] done → /workspace/bun/vendor/picohttpparser
[2/184] gen cpp.rs (cppbind)
[3/184] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[4/184] gen JS modules (bundle-modules)
Preprocess modules (14336ms)
Bundle modules (83ms)
Postprocesss modules (159ms)
Bundle Functions (561ms)
Generate Code (43ms)

[15.19s] Bundled "src/js" for development
  2791 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/184] cargo bun_runtime → libbun_runtime.a
[177/184] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
FAILED: obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o 
/usr/bin/ccache /usr/lib/llvm-21/bin/clang++ -march=nehalem -O0 -glldb -g3 -gz=zstd -fno-standalo
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (4ff919377)

test/js/bun/css/nested-selector-list-split-hang.test.ts:
(pass) splitting target-incompatible selectors out of a rule keeps both halves in source order [14.09ms]
(pass) splitting a long nested selector list into separate rules is linear [566.78ms]

 2 pass
 0 fail
 6 expect() calls
Ran 2 tests across 1 file. [1013.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/css/nested-selector-list-split-hang.test.ts
bun test v1.4.3 (5f554969b)

test/js/bun/css/nested-selector-list-split-hang.test.ts:
(pass) splitting target-incompatible selectors out of a rule keeps both halves in source order [13.31ms]
(pass) splitting a long nested selector list into separate rules is linear [2894.65ms]

 2 pass
 0 fail
 6 expect() calls
Ran 2 tests across 1 file. [5.77s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     2fa92b585d
  features     baseline

23 deps, 131 codegen, 1172 objects in 800ms

ninja: Entering directory `/workspace/bun/build/release'
[1/143] fetch picohttpparser
[picohttpparser] up to date
[2/143] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/143] gen cpp.rs (cppbind)
[4/143] gen JS modules (bundle-modules)
Preprocess modules (12292ms)
Bundle modules (60ms)
Postprocesss modules (174ms)
Bundle Functions (588ms)
Generate Code (49ms)

[13.18s] Bundled "src/js" for production
  2594 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[4/142] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/borin
... (truncated)
diff hotspot
src/css/rules/mod.rs                               | 20 +++----
 .../css/nested-selector-list-split-hang.test.ts    | 63 ++++++++++++++++++++++
 2 files changed, 70 insertions(+), 13 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                     reads  edits  tests
src/css/rules/mod.rs                                         1      4     11
test/js/bun/css/nested-selector-list-split-hang.test.ts      2      3     11

When a style rule's selectors are not all compatible with the targets
and cannot be wrapped in :is(), minify_style_arm moves the incompatible
ones into one rule each. It removed them from the selector list one at
a time with ordered_remove(i) and then drained the two follow-up lists
with ordered_remove(0), each of which shifts the rest of the list, so a
rule with n incompatible selectors was O(n^2). Every selector of a
nested rule is incompatible when nesting has to be compiled away, which
the default bun build targets require: a 120 KB nested rule with 16k
selectors took 6 s.

Partition the list in one pass and consume the follow-up lists by
value. The output is unchanged.
@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 8a3063f4-a82d-4814-8ac7-5be509fb6d9c

📥 Commits

Reviewing files that changed from the base of the PR and between e5f9986 and 2fa92b5.

📒 Files selected for processing (2)
  • src/css/rules/mod.rs
  • test/js/bun/css/nested-selector-list-split-hang.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The CSS minifier now partitions selector lists and emits incompatible rules through direct iteration. Regression tests cover source order, nested selector expansion, and performance for large nested selector lists.

Changes

CSS selector iteration

Layer / File(s) Summary
Selector partitioning and regression coverage
src/css/rules/mod.rs, test/js/bun/css/nested-selector-list-split-hang.test.ts
Selector processing replaces repeated removal with direct iteration. Tests validate ordering, nested expansion, and runtime behavior for large selector lists.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 2fa92

The selector-splitting optimization preserves output behavior and is covered by regressions for ordering, nesting, and performance.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the primary change: it makes target-incompatible CSS selector splitting linear time.
Description check ✅ Passed The description explains the problem, fix, performance impact, verification steps, regression coverage, and relevant background. It provides the information required by the template, although it uses …

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

@robobun

robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.2 and on a debug build of main with the ledger script: Bun.build of .a,.b{ .c0,…,.cN {color:red} } took 62 / 331 / 965 / 4104 ms for n = 2048 / 4096 / 8192 / 16384 (release), against 2 to 10 ms for the same list at the top level. With this branch the nested case takes 5 / 9 / 10 / 20 ms.

The regression test (test/js/bun/css/nested-selector-list-split-hang.test.ts) fails on 1.4.2 (nested/flat ratio 2618x) and on a debug build of main (21x), and passes here (2x to 3x, under 3 s on a debug build).

CI: 180 of 181 jobs pass and the new test passes on every lane. The one red job (debian 13 x64-asan) fails only in test/js/bun/http/serve-pending-promise-abort-leak.test.ts, which also fails on main and which this PR does not touch.

@claude claude 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.

LGTM — a clean linear-time rewrite of the three ordered_remove drain loops with order preserved.

What was reviewed:

  • Verified the mem::take partition and both consuming for … in list loops preserve iteration order, so output is byte-identical; SmallList wraps SmallVec and has Default + owning IntoIterator, so no drop/ownership hazards.
  • Checked for other ordered_remove(0) / ordered_remove(i) drain sites in src/css/ — none remain in this pass.
  • The new test mirrors the sibling *-hang.test.ts files (same targets encoding, same 90 s outlier timeout) and asserts a self-relative ratio rather than a wall-clock threshold; minifyTest is registered with arity 3 so the targets argument is honored.
Extended reasoning...

Overview

The PR touches one function, minify_style_arm in src/css/rules/mod.rs, replacing an index-based ordered_remove(i) partition and two while len > 0 { ordered_remove(0) } drain loops with core::mem::take + re-append and consuming for … in list iteration. It adds test/js/bun/css/nested-selector-list-split-hang.test.ts, a ratio-based perf guard driving cssInternals.minifyTest from bun:internal-for-testing.

Security risks

None. This is safe Rust over an internal SmallList (a smallvec::SmallVec newtype in src/collections/lib.rs) with Default and owning IntoIterator impls, so mem::take leaves a valid empty list and by-value iteration drops correctly. No untrusted-input parsing, FFI, or auth/crypto paths are involved.

Level of scrutiny

Low-to-moderate. The change is ~30 lines, mechanical, and order-preserving — the compatible selectors are appended back in encounter order, and both follow-up lists are consumed front-to-back exactly as the old ordered_remove(0) loops did, so emitted rule order is unchanged. This is precisely the "no orderedRemove(0) draining of unbounded queues" pattern REVIEW.md flags; a repo-wide grep of src/css/ shows no other ordered_remove(0)/ordered_remove(i) drain sites in this minify pass.

Other factors

The new test file follows the established local convention alongside duplicate-declaration-merge-hang.test.ts, atan2-backtracking-hang.test.ts, and angle-serialization-hang.test.ts in the same directory — same { chrome: 80 << 16 } target encoding, same 90 s per-test timeout for a perf-guard outlier, and bun:internal-for-testing rather than adding production hooks. It asserts output correctness first (toStartWith/toEndWith on the split rules) and then a machine-speed-independent best-of ratio (nested vs. same-size flat baseline, < 8×), which is the sanctioned form for complexity guards and avoids absolute wall-clock flakiness under debug/ASAN. The bug-hunt exit reason was dry_streak and no findings or prior objections exist on the PR.

Take the asserted output from the timed runs, sample once when a run is
slow (debug builds), build the 50k selector list with a native join, and
drop the per-test timeout. The test now takes under 3 s on a debug
build. Also cover the order of a mixed compatible/incompatible list.
@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:43 PM PT - Sep 10th, 2026

❌ @robobun, your commit 2fa92b5 has 1 failures in Build #114055 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42243

That installs a local version of the PR into your bun-42243 executable, so you can run:

bun-42243 --bun

@claude claude 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.

LGTM — the follow-up commit tightened the test to fit the default timeout and added the source-order correctness case.

What was reviewed:

  • The three ordered_remove loops in minify_style_arm are replaced with owning iteration; order is preserved in each (take-and-reappend for the partition, in-order for for the two drains), so output is byte-identical.
  • core::mem::take on sty.selectors.v leaves a valid empty SmallList (Default + IntoIterator are implemented in src/collections/lib.rs), and incompatible losing mut is correct since it's now consumed by value.
  • The perf test is self-relative (nested vs. flat on the same machine) with an 8× bound against a 2-4× fixed / 19×+ unfixed gap, and the new file matches the existing *-hang.test.ts pattern in test/js/bun/css/.
Extended reasoning...

Overview

This PR fixes an accidentally-quadratic loop in the CSS minifier's minify_style_arm (src/css/rules/mod.rs). Three sites drained a SmallList via ordered_remove(0) / ordered_remove(i), shifting the tail on every removal. The fix replaces them with owning iteration: the selector partition now core::mem::takes the list and re-appends each selector into either the kept list or incompatible, and the two follow-up drain loops become for … in over the owned lists. Net Rust change is ~15 lines. A new test file adds a source-order correctness check for both partition halves (top-level and nested) plus a self-relative perf ratio test (50k-selector nested rule vs. the same list flat).

Security risks

None. This is a pure algorithmic-complexity change to an internal CSS minifier code path. No parsing of new input shapes, no new external boundaries, no unsafe code, no allocation-size arithmetic on untrusted data beyond what already existed.

Level of scrutiny

Low-to-moderate. The transformation is mechanical and each of the three replacements is locally verifiable as order-preserving: the partition iterates in source order and appends to two lists; the two drain loops previously popped index 0 in a while, which is exactly front-to-back iteration. SmallList implements both Default and IntoIterator (src/collections/lib.rs), so core::mem::take yields a valid empty list to re-append into and the by-value for is well-defined. REVIEW.md explicitly names orderedRemove(0) draining as a pattern to eliminate, so this is the canonical fix.

Other factors

A prior review under this app's identity left comments; the author pushed a follow-up commit that (a) dropped the 90 s per-test timeout override by shrinking the workload (60k→50k selectors, 2 s→400 ms bench budget, faster string construction), and (b) added an explicit correctness test asserting exact minified output for both compatible and incompatible halves in source order. The new test file follows the established *-hang.test.ts convention already used in test/js/bun/css/ (e.g. duplicate-declaration-merge-hang.test.ts, angle-serialization-hang.test.ts), so a standalone file is consistent with local practice. No CODEOWNERS cover src/css/. No outstanding CHANGES_REQUESTED reviews. Bug-hunt exit reason was dry_streak with zero findings.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants