Skip to content

Stop zero-value minification from producing invalid CSS - #13

Merged
marevol merged 1 commit into
mainfrom
fix/css-color-percentage
Sep 5, 2026
Merged

Stop zero-value minification from producing invalid CSS#13
marevol merged 1 commit into
mainfrom
fix/css-color-percentage

Conversation

@marevol

@marevol marevol commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Problem

Two zero-value minification rules in CssCompressor rewrite a zero into a form the CSS grammar does not accept in that position. The browser then drops the whole declaration, so the rule costs the declaration in order to save a byte — silently, with no warning from the build.

1. Zero-percentage stripping inside a group

input output why it breaks
hsla(0,0%,100%,.5) hsla(0,0,100%,.5) legacy comma hsl()/hsla() take <percentage> for saturation and lightness
rgb(0%,50%,100%) rgb(0,50%,100%) legacy comma rgb() is all-<percentage> or all-<number>, never mixed
color-mix(in srgb,red 0%,blue) color-mix(in srgb,red 0,blue) color-mix() takes <percentage [0,100]>
min(0%,10px) min(0,10px) math functions type-check their arguments: <number> vs <length>

The underlying rule — "a zero <length> may drop its unit" — is correct, but it was applied to <percentage> as well, and it fired on anything inside a (, so it reached colour and math functions it was never meant for.

2. Collapsing a run of zeroes

input output why it breaks
box-shadow:0 0 box-shadow:0 a <shadow> needs both offsets
text-shadow:0 0 0 text-shadow:0 same
perspective-origin:0 0 perspective-origin:0 one value means 0 center, not 0 0

margin:0 0 0 0margin:0 is the box-model shorthand and collapses exactly; box-shadow is not a shorthand at all. Losing a box-shadow declaration also takes down every other shadow in the same comma-separated list. flex was already excluded here for the same class of reason.

Both defects predate the fork — yuicompressor-2.4.8 from Maven Central produces the same output — and the first was already recorded in KnownCssLimitationsTest with instructions to delete the entries once fixed.

Fix

  • The % branch of the in-group zero-unit stripping is split out and now consults the enclosing function name against PERCENTAGE_REQUIRED_FUNCTIONS. The name is captured rather than looked behind, so min does not also match the tail of minmax(), where a zero really may lose its unit.
  • The zero-run collapse now matches on the property name (vendor prefix stripped) and skips ZERO_RUN_NOT_COLLAPSIBLE.

Unchanged: length units inside groups (translate(0px,10px)translate(0,10px)), gradient colour stops (red 0%red 0), minmax(0%,1fr)minmax(0,1fr), margin/padding/border-radius/gap collapsing, and the background-position/transform-origin re-expansion.

Verification

  • Corpus: 143 real-world stylesheets (Bootstrap, Font Awesome, Swiper, Vuetify, VitePress and Fess/CodeLibs themes) compressed, then every declaration checked against the CSS grammar with css-tree's lexer. Declarations that are valid in the source but invalid after compression: 26 files before, 0 after. The 16 files still flagged are var() fallbacks css-tree cannot resolve — flagged identically in the source.
  • All 143 remain byte-identical when compressed a second time.
  • CssColorFunctionTest (new, 20 cases) pins every corrupted value plus the neighbouring cases that must keep compressing.
  • KnownCssLimitationsTest: the three percentage rows and the min() case are removed, as that file instructs. The <time>-inside-a-group case is untouched and still recorded.
  • Full suite: 780 tests, 0 failures, 3 skipped.

Two long-standing rules in CssCompressor rewrite a zero into a form the CSS
grammar does not accept in that position. A browser drops the whole
declaration, so the rule costs the declaration to save a byte, and it does so
silently - the build reports no warning.

Zero-percentage stripping inside a group

  hsla(0,0%,100%,.5)          -> hsla(0,0,100%,.5)
  rgb(0%,50%,100%)            -> rgb(0,50%,100%)
  color-mix(in srgb,red 0%,b) -> color-mix(in srgb,red 0,b)
  min(0%,10px)                -> min(0,10px)

The rule "a zero <length> may drop its unit" is true, but it was applied to
<percentage> as well, and it fired on anything inside a "(". The colour
functions' comma-separated legacy form takes <percentage> arguments, and the
math functions type-check their arguments against each other, so none of the
outputs above is a valid value.

The "%" case now consults the enclosing function name. The name is captured
rather than looked behind, so "min" does not also match the tail of "minmax()",
where a zero really may lose its unit. Length units inside groups, gradient
colour stops and minmax() are unchanged.

Collapsing a run of zeroes

  box-shadow:0 0              -> box-shadow:0
  text-shadow:0 0 0           -> text-shadow:0
  perspective-origin:0 0      -> perspective-origin:0

"margin:0 0 0 0" is the box-model shorthand and collapses exactly, but a
<shadow> needs both of its offsets, so "box-shadow:0" is invalid - and taking
the declaration down takes every other shadow in the same comma-separated list
with it. "perspective-origin:0" means "0 center", not "0 0". The collapse now
matches on the property name (vendor prefix removed) and skips those three;
"flex" keeps the exclusion it already had.

Verification

- 143 real-world stylesheets (Bootstrap, Font Awesome, Swiper, Vuetify and
  Fess/CodeLibs themes) were compressed and every declaration checked against
  the CSS grammar with css-tree's lexer. Declarations valid in the source but
  invalid after compression: 26 files before, 0 after - the 16 files still
  flagged are var() fallbacks css-tree cannot resolve, and are flagged
  identically in the source.
- All 143 stay byte-identical when compressed a second time.
- CssColorFunctionTest pins each corrupted value plus the neighbouring cases
  that must keep compressing: minmax(), gradient stops, translate(0px),
  margin/padding/border-radius/gap, background-position, transform-origin.
- The three percentage entries and the min() entry in KnownCssLimitationsTest
  recorded these defects with instructions to delete them once fixed; they are
  removed. The <time>-inside-a-group case there is untouched and still fails.
@marevol marevol added this to the 2.4.11 milestone Sep 5, 2026
@marevol marevol self-assigned this Sep 5, 2026
@marevol
marevol merged commit 098c5ee into main Sep 5, 2026
11 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant