Repository navigation
Description
Have you used AI?
Yes
Which part of webpack does this concern?
css
Feature Proposal
Merge non-adjacent rules that share a declaration block, behind an option — and gate each merge on a byte test so it can never make a stylesheet bigger.
cssMinify today joins two rules sharing a block only when nothing stands between them (lib/css/cssMinify.js names the non-adjacent case as a declined transform). Fusing the non-adjacent ones as well is worth real bytes, but doing it unconditionally regresses some stylesheets: merging removes repeated {block} text that gzip was already encoding as a cheap back-reference, and pays for it with a longer, novel selector list.
The regression is fully predicted by what the merge trades. Over 16 framework stylesheets, every sheet that got bigger has a duplicate-block-to-selector ratio below 1, and every sheet that got smaller is above it:
| stylesheet | avg block | avg selector | ratio | unguarded gzip |
|---|---|---|---|---|
| UIkit 3 | 18 | 153 | 0.12 | +553 |
| Pure 3 | 21 | 87 | 0.25 | +6 |
| sanitize.css 13 | 8 | 24 | 0.33 | +2 |
| Bootstrap 5 | 34 | 18 | 1.86 | −1675 |
| Primer 21 | 264 | 17 | 15.73 | −64601 |
So the guard is a comparison, not a compressor — no minifier can run gzip in its inner loop. Merge a group only when the block bytes removed exceed a quarter of the selector text being fused:
block.length * (rules - 1) > selectorTextLength / 4
That removes every regression and ends up smaller than merging unconditionally, because the groups it declines were costing bytes:
| unconditional | guarded | |
|---|---|---|
| 16 stylesheets | −74,357 | −75,348 |
| excluding Primer 21 (an outlier at −64k) | −9,756 | −10,719 |
| stylesheets that got bigger | 3 | 0 |
| stylesheets that got smaller | 12 | 14 |
Per-sheet, guarded: UIkit −867 (from +553), Bootstrap −1629, Bulma −2952, Semantic UI −2074, Tachyons −1618, Pure −2.
Feature Use Case
Bootstrap loses 5.4% of its gzipped size to blocks repeated at a distance, and Primer 21 far more. Both are shipped as-is by a lot of applications.
The option is wanted regardless of the guard, since the transform reorders the cascade: it is only sound where every selector in the fused list is a shape each target engine parses, and where nothing between the two rules can match the same element. csso merges without that constraint and drops ~12,900 Tailwind classes doing it.
Additional Context
Measured by fusing every duplicate block in cssMinify's own output and gzipping (level 9), so the numbers are a ceiling a cascade-safe implementation merges fewer groups and would land under them. The corpus is the one tooling/compare-css-minifiers.js already installs.
Happy to open the PR for this
Have you used AI?
Yes I used it to script the measurement and sweep the guard shapes across the 16 stylesheets. Every number here comes out of an actual run against cssMinify, not an estimate, and I checked the regression holds up without the Primer outlier before writing it down.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Start date
Target date
Effort
Activity
aryanraj45 commentedon Sep 1, 2026
Correcting the sizes in the description — they are far too large.
The finding holds: the merge does make some stylesheets bigger, the ratio predicts which, and the guard both removes most of that and comes out ahead overall. Measured over the same 15 stylesheets:
The scale is what was wrong. The
−74,357/−75,348above came from fusing every duplicate block in the minified output with no check on what stood between the two rules, so they are the ceiling for a transform nobody can ship. A sound merge gives −39.It is sound only where nothing between the two rules declares a property the shared block does — such a rule won before the merge and loses after it. With that check, of the candidates that reach it:
Treating an at-rule as declaring everything is the conservative part, and it is not what costs the most: lifting it (unsound, measured only to size it) moves the total by 7 bytes. What limits this is that rules sharing a block are nearly always separated by rules touching the same properties, which is what a framework's source order looks like.
Closing that gap needs the intervening rule's selectors weighed as well: one declaring
colorbetween two others only matters if it can match an element they match. That is the analysis csso does, and where it drops ~12,900 Tailwind classes.feat(css): join rules a third stands between, behind an option
alexander-akait commentedon Sep 5, 2026
Shipped in #21969 as
optimization.minimize.css.mergeDistantRules, off by default, with #21972 correcting its size guard. Closing as done — but the savings land nowhere near the projection here, and it is worth writing down why, because the ceiling in the issue is not one a cascade-safe implementation can approach.Measured on the same corpus at gzip level 9, the shipped option is −1028 raw / −35 gzip across the fixtures it moves at all, against the −75,348 projected over 16 sheets. The gap is not the byte test. It is the soundness constraint the issue itself names: a merge holds only where nothing standing between the two rules declares a property the shared block sets, and where no at-rule sits between them that the printer cannot see into. That declines the overwhelming majority of duplicate-block groups — including essentially all of Primer 21's −64,601, which comes from fusing blocks across rules that do set the same properties, and so changes computed style.
The size guard is not the limiter, and the sweep says so directly: with no size guard at all, merging every group the cascade check allows is only −3343 raw / +38 gzip over 27 stylesheets. Every value of the guard constant is bounded by that, so no tuning of
block.length * (rules - 1) > selectorTextLength / 4or any relative of it reaches the projected numbers.Two smaller corrections to the analysis, for anyone reading this later:
selectorTextLength / 4ratio was measured against unguarded fusion, so it is fitted to a population of merges the sound version never performs. On the merges that survive the cascade check it is close to a no-op — it declines almost nothing, which is exactly the bug fix(css): take a distant-rule merge only where it earns wire bytes #21972 fixes.Thanks for the proposal and the measurement scripts — the corpus and the gzip-level-9 methodology were what made the guard sweep tractable, and your work on #21908 is carried into #21969 with co-authorship.
Generated by Claude Code