Skip to content

Merge non-adjacent rules sharing a declaration block, guarded so it cannot grow a stylesheet #21880

Closed

Description

@aryanraj45

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.

Activity

  1. added
    area: configOptions, defaults, validation (lib/config, schemas/)
    area: cssCSS support (lib/css)
    on Aug 31, 2026
  2. aryanraj45 commented on Sep 1, 2026

    @aryanraj45
    MemberAuthor

    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:

    total gzip worst sheet
    no guard −32 +5
    guarded −39 +2

    The scale is what was wrong. The −74,357 / −75,348 above 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:

    stylesheet candidates guard at-rule between property shadowed merged
    Bootstrap 5 79 21 28 27 3
    UIkit 3 247 87 135 23 2
    Primer 21 140 21 89 26 4

    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 color between 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.

  3. added a commit that references this issue on Sep 4, 2026
    2156c60
  4. alexander-akait commented on Sep 5, 2026

    @alexander-akait
    Member

    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 / 4 or any relative of it reaches the projected numbers.

    Two smaller corrections to the analysis, for anyone reading this later:

    • The proposed selectorTextLength / 4 ratio 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.
    • Sorting sheets by average block-to-selector ratio predicts the unguarded regression well, but the ratio is a per-sheet average and the decision is per-group; the shipped guard therefore compares the actual block against the actual selector at each site rather than consulting a sheet-level statistic.

    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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: configOptions, defaults, validation (lib/config, schemas/)area: cssCSS support (lib/css)

    Fields

    Priority

    None yet

    Start date

    None yet

    Target date

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions