Skip to content

feat(css): give every target-driven rewrite a switch of its own - #21932

Merged
alexander-akait merged 2 commits into
perf/css-drop-what-is-already-saidfrom
feat/css-lower-unsupported-switch
Sep 3, 2026
Merged

alexander-akait merged 2 commits into
perf/css-drop-what-is-already-saidfrom
feat/css-lower-unsupported-switch

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

Summary

Adds optimization.minimize.css.lowerUnsupported, so the target-driven lowering can be turned off on its own when a rewrite is wrong for a project, and audits the rest: every rewrite the minifier makes now answers to a switch. That audit found a real bug — the streaming and collected printer paths disagreed about removeDeadRules. Also minifies what a lowering reads and what it writes, so a lowered value is not left in a longer spelling than the one it replaced.

Top of the stack, on #21931 — review the top two commits only.

What kind of change does this PR introduce?

feat

Did you add tests for your changes?

Yes — test/CssSyntax.unittest.js (a describe asserting every rewrite has a switch), test/configCases/css/minimize-lower-unsupported-off/, and test/configCases/css/minimize-transforms-off/.

Does this PR introduce a breaking change?

No — the new option defaults to the current behaviour.

If relevant, what needs to be documented once your changes are merged or what have you already documented?

optimization.minimize.css.lowerUnsupported.

Use of AI

Yes. Written with Claude Code: it audited the rewrites against the switches, added the option, and wrote the tests; every change was reviewed before pushing.

🤖 Generated with Claude Code

https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 049c5c47-9613-4739-8498-33bacf00a25c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@changeset-bot

changeset-bot Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 690d225

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added area: config Options, defaults, validation (lib/config, schemas/) area: css CSS support (lib/css) area: types types.d.ts, JSDoc annotations, hand-maintained declarations area: tooling Repo tooling, codegen, CLI entry (tooling/, bin/) labels Sep 3, 2026
@alexander-akait
alexander-akait force-pushed the perf/css-drop-what-is-already-said branch from f02a4ea to d6af4cc Compare September 3, 2026 08:52
@alexander-akait
alexander-akait force-pushed the feat/css-lower-unsupported-switch branch from 8c87a6b to 626115f Compare September 3, 2026 08:52
`optimization.minimize.css` says every transform keeping the stylesheet's
meaning may be turned off on its own, so a document one rewrite breaks can be
minimized without it. Eleven rewrites did not honour that: everything the
browserslist target drives — a hex alpha as `rgba()`, a double-position stop,
`inset` / `overflow` / `place-*` as longhands, `text-decoration` as its own,
`system-ui` as the platform stack, a `:lang()` or `:not()` list as the `:is()`
it means, a media range as the `min-`/`max-` pair, `light-dark()` as the
custom-property pair — could only be escaped by dropping the target, which takes
vendor prefixing with it. `lowerUnsupported` turns the policy off and leaves the
rest of the minifier running; it does not make the printer write a spelling the
target cannot read.

Three more rewrites answered to nothing, and now answer to the switch that owns
them: `@charset` and an empty rule on the collected path to `removeDeadRules`
(the streaming path already asked it, so the two paths disagreed), and the `all`
before an `and` and a repeated condition operand to `shortenMediaQueries` —
whose description now says it shortens a condition prelude, `@supports` and
`@container` included.

A unit test asks each of the eleven to stand still with the switch off, and
`minimize-lower-unsupported-off` is the same sheet and target as
`minimize-lower-unsupported` with the one option set.
Asked whether a lowered spelling is itself minified, two answers came back no:

- A media feature's own number was never shortened, lowered or not:
  `@media (0480.0px <= width <= 768.50px)` kept every digit it was written with,
  and the `min-`/`max-` pair lowered out of it kept them too. A feature is a
  `<length>`, a `<ratio>` or an `<integer>` and nothing else, so the number in
  one shortens like any other — where a prelude number elsewhere is An+B and
  stripping a `+` there breaks the selector, which is what the guard was for. A
  `style()` query still compares its tokens as written, and `@supports` reads a
  declaration rather than a feature.
- A `text-decoration` whose color is a hex or a call was not lowered at all. The
  print leaves out the separator between an ident and a hash — `solid #fff` is
  `solid#fff`, two tokens whatever stands between them — and the lowering split
  the printed value on its spaces, so the slots came back unreadable and the
  rewrite declined. For Safari 15 that means the whole declaration is dropped
  and the underline is lost, which is the failure the lowering exists to
  prevent. It now splits at the junctions the print could have left bare, by the
  same test the print makes; and a call that is a color rather than one that
  takes one fills a color slot, out of `<color>`'s own grammar.

Everything else a lowering writes was already minified: the `inset` longhands
drop a zero's unit, the `text-decoration` ones shorten the color, the
`light-dark()` halves shorten theirs, the hex-alpha rewrite writes the shortest
alpha that quantizes back, and the `system-ui` stack drops its quotes.
@alexander-akait
alexander-akait force-pushed the perf/css-drop-what-is-already-said branch from d6af4cc to af2282e Compare September 3, 2026 09:22
@alexander-akait
alexander-akait force-pushed the feat/css-lower-unsupported-switch branch from 626115f to 690d225 Compare September 3, 2026 09:22
@alexander-akait
alexander-akait merged commit 0c5a7ad into perf/css-drop-what-is-already-said Sep 3, 2026
6 checks passed
@alexander-akait
alexander-akait deleted the feat/css-lower-unsupported-switch branch September 3, 2026 11:23
alexander-akait added a commit that referenced this pull request Sep 3, 2026
* feat(css): give every target-driven rewrite a switch of its own

`optimization.minimize.css` says every transform keeping the stylesheet's
meaning may be turned off on its own, so a document one rewrite breaks can be
minimized without it. Eleven rewrites did not honour that: everything the
browserslist target drives — a hex alpha as `rgba()`, a double-position stop,
`inset` / `overflow` / `place-*` as longhands, `text-decoration` as its own,
`system-ui` as the platform stack, a `:lang()` or `:not()` list as the `:is()`
it means, a media range as the `min-`/`max-` pair, `light-dark()` as the
custom-property pair — could only be escaped by dropping the target, which takes
vendor prefixing with it. `lowerUnsupported` turns the policy off and leaves the
rest of the minifier running; it does not make the printer write a spelling the
target cannot read.

Three more rewrites answered to nothing, and now answer to the switch that owns
them: `@charset` and an empty rule on the collected path to `removeDeadRules`
(the streaming path already asked it, so the two paths disagreed), and the `all`
before an `and` and a repeated condition operand to `shortenMediaQueries` —
whose description now says it shortens a condition prelude, `@supports` and
`@container` included.

A unit test asks each of the eleven to stand still with the switch off, and
`minimize-lower-unsupported-off` is the same sheet and target as
`minimize-lower-unsupported` with the one option set.

* fix(css): minify what a lowering reads and what it writes

Asked whether a lowered spelling is itself minified, two answers came back no:

- A media feature's own number was never shortened, lowered or not:
  `@media (0480.0px <= width <= 768.50px)` kept every digit it was written with,
  and the `min-`/`max-` pair lowered out of it kept them too. A feature is a
  `<length>`, a `<ratio>` or an `<integer>` and nothing else, so the number in
  one shortens like any other — where a prelude number elsewhere is An+B and
  stripping a `+` there breaks the selector, which is what the guard was for. A
  `style()` query still compares its tokens as written, and `@supports` reads a
  declaration rather than a feature.
- A `text-decoration` whose color is a hex or a call was not lowered at all. The
  print leaves out the separator between an ident and a hash — `solid #fff` is
  `solid#fff`, two tokens whatever stands between them — and the lowering split
  the printed value on its spaces, so the slots came back unreadable and the
  rewrite declined. For Safari 15 that means the whole declaration is dropped
  and the underline is lost, which is the failure the lowering exists to
  prevent. It now splits at the junctions the print could have left bare, by the
  same test the print makes; and a call that is a color rather than one that
  takes one fills a color slot, out of `<color>`'s own grammar.

Everything else a lowering writes was already minified: the `inset` longhands
drop a zero's unit, the `text-decoration` ones shorten the color, the
`light-dark()` halves shorten theirs, the hex-alpha rewrite writes the shortest
alpha that quantizes back, and the `system-ui` stack drops its quotes.
alexander-akait added a commit that referenced this pull request Sep 3, 2026
…21931)

* perf(css): drop what a declaration, a slot or a query already says

Five things the minifier wrote out that say nothing:

- A declaration a later one overrides, where the two are spelled from one
  vocabulary — a pair of numbers carrying one unit, a pair of hexes on one side
  of the alpha four digits state, a pair of color names as old as naming a color,
  or `inherit`. That is what parts an override from a fallback without asking
  the target: `color:#eee` before `color:lab(…)` is the older color standing
  where the newer one is not read, while `color:#eee;color:#333` is one an engine
  reading either reads both of. Two adjacent rules with one selector join on the
  same terms, where they used to be kept apart by any property they shared.
- A shorthand slot holding its own initial, where the slot takes more than
  keywords: `border:medium none currentcolor` is `border:none`. The initial table
  now carries what each slot takes beyond its spellings, so a sibling filling it
  is read by the classes its own value is in — a length, a color — rather than by
  a list of names, and a value that classes as nothing keeps the slot.
- A `<time>` of zero nothing after it depends on, in a transition or an
  animation, and an iteration count of one.
- The `all` a media query states before an `and`, which matches what the
  condition alone does.
- An operand a condition already states, in `@supports`, `@media` and
  `@container` alike.

`rebeccapurple` and `transparent` are the names left out of the first, derived
from the compatibility rows they carry of their own: an engine may read neither
while reading the rest.

Held to Chromium: every rewrite computes as what it replaced, over the
equivalence fixtures a new one covers.

* test(css): hold every lowering to the whole computed style, not the probes

The lowering tier compared the properties each fixture names. It now compares
every property the CSSOM reports for those elements, so a rewrite reaching one
nobody thought to probe is one the tier sees. Two differences it turned up are
real and now stated rather than hidden:

- a folded color serializes differently. Chromium keeps the space a color was
  written in, so `color(srgb .2 .4 .6)` computes as itself where `#369` computes
  as `rgb(51, 102, 153)` — the same color, a different string for a script to
  read. Values are compared as the color they paint, which is what the rewrite
  preserves.
- lowering `light-dark()` writes `--webpack-light` and `--webpack-dark`, which a
  script enumerating computed custom properties would see where the source has
  none. The fixtures naming that lowering now say so.

The corpus tier already reads every `.css` under `configCases` and compares the
CSSOM rule by rule — conditions evaluated rather than compared as text, blocks
computed by the engine — and every `style=""` as the declarations it parses to.
The queries and shorthand slots this session learned to shorten had no corpus
entry, so they were held by unit tests alone; they have one now.

* feat(css): give every target-driven rewrite a switch of its own (#21932)

* feat(css): give every target-driven rewrite a switch of its own

`optimization.minimize.css` says every transform keeping the stylesheet's
meaning may be turned off on its own, so a document one rewrite breaks can be
minimized without it. Eleven rewrites did not honour that: everything the
browserslist target drives — a hex alpha as `rgba()`, a double-position stop,
`inset` / `overflow` / `place-*` as longhands, `text-decoration` as its own,
`system-ui` as the platform stack, a `:lang()` or `:not()` list as the `:is()`
it means, a media range as the `min-`/`max-` pair, `light-dark()` as the
custom-property pair — could only be escaped by dropping the target, which takes
vendor prefixing with it. `lowerUnsupported` turns the policy off and leaves the
rest of the minifier running; it does not make the printer write a spelling the
target cannot read.

Three more rewrites answered to nothing, and now answer to the switch that owns
them: `@charset` and an empty rule on the collected path to `removeDeadRules`
(the streaming path already asked it, so the two paths disagreed), and the `all`
before an `and` and a repeated condition operand to `shortenMediaQueries` —
whose description now says it shortens a condition prelude, `@supports` and
`@container` included.

A unit test asks each of the eleven to stand still with the switch off, and
`minimize-lower-unsupported-off` is the same sheet and target as
`minimize-lower-unsupported` with the one option set.

* fix(css): minify what a lowering reads and what it writes

Asked whether a lowered spelling is itself minified, two answers came back no:

- A media feature's own number was never shortened, lowered or not:
  `@media (0480.0px <= width <= 768.50px)` kept every digit it was written with,
  and the `min-`/`max-` pair lowered out of it kept them too. A feature is a
  `<length>`, a `<ratio>` or an `<integer>` and nothing else, so the number in
  one shortens like any other — where a prelude number elsewhere is An+B and
  stripping a `+` there breaks the selector, which is what the guard was for. A
  `style()` query still compares its tokens as written, and `@supports` reads a
  declaration rather than a feature.
- A `text-decoration` whose color is a hex or a call was not lowered at all. The
  print leaves out the separator between an ident and a hash — `solid #fff` is
  `solid#fff`, two tokens whatever stands between them — and the lowering split
  the printed value on its spaces, so the slots came back unreadable and the
  rewrite declined. For Safari 15 that means the whole declaration is dropped
  and the underline is lost, which is the failure the lowering exists to
  prevent. It now splits at the junctions the print could have left bare, by the
  same test the print makes; and a call that is a color rather than one that
  takes one fills a color slot, out of `<color>`'s own grammar.

Everything else a lowering writes was already minified: the `inset` longhands
drop a zero's unit, the `text-decoration` ones shorten the color, the
`light-dark()` halves shorten theirs, the hex-alpha rewrite writes the shortest
alpha that quantizes back, and the `system-ui` stack drops its quotes.

* fix(css): drop no declaration a keyframe, a revert or a slot's arity keeps

The browser tier over web-platform-tests caught three colors and animations this
turn's own redundancy removal changed the meaning of.

- CSS Animations 1 §3 ignores an `!important` declaration inside a keyframe, so
  the plain one before it is what the engine reads. `border-color:green;
  border-color:red!important` kept only the important one, which the engine then
  discards — the element lost its border color rather than gaining a red one
  (`css-cascade/important-prop.html`).
- The rule-level override read the later block's *first* declaration of the
  property rather than its last, so `#t{color:green}` before
  `#t{color:red;color:revert-rule}` was compared against the red and dropped.
  `revert-rule` reads back to that very rule, so the color went green to red
  (`css-cascade/revert-rule-layer.html`). The last one decides now, which is
  also what leaves the `revert` family declining on its own terms.
- One slot takes the iteration count and two take a time, so `animation:1 2`,
  `animation:1 1`, `animation:0s 0s 0s` and `transition:0s 0s 0s` are layers no
  engine reads — and dropping the default from one handed back a layer that
  runs. Both droppers decline where a layer names more than the slots take.

The folds that were right stay: `animation:0s 0s` is still `animation:0s`,
`animation:x 1 2s` still loses its count, and an `!important` outside a keyframe
still stands over what precedes it.

The two changesets this pull request carried are one, as the guide asks, and the
`};` recovery case in the html suite names two properties now — with one, the
override removal takes the pair it was written to show.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018tNx7sU4SbnKFhFeofDVr5

* fix(css): let a url() answer as a url before it is read as a call

`_valueClasses` tested for a function call before it tested for `url()`,
so a well-formed `url(...)` reached the call branch first and left the
url test unreachable. Reword `lowerUnsupported`'s closing clause, which
did not parse.

* style(css): hold the new comments to the two-line limit

Also drops a leftover `@returns`-only JSDoc block stacked above
`collectColorFunctions`'s real one.

* test(css): drive the two dataset guards the generator grew

`collectColorFunctions` and `collectLaterColorNames` read their dataset
off module state, so neither guard could be reached from a test. Take it
as an argument the way `collectGradientFunctions` and
`collectMergeableAtRules` already do, and cover both throws.

* docs: make the comment-length limit countable, and check it

The rule said "as short as possible — ideally one line, at most two" in
the middle of a paragraph, which reads as a preference rather than a
bound, and nothing checked it before review did. State the limit first,
give a command that reports any comment block a change grows past two
lines, and cut the section from 33 lines to 19. Bring the comments this
branch added under it.

* test(css): update the CLI snapshot for the reworded description

The CLI flags are derived from the schema, so rewording
`lowerUnsupported` changed four lines of `Cli.basictest.js.snap`.

* build: check comment length over both comment forms

The awk one-liner the guide carried matched `//` runs only, so a
three-line `/* … */` block passed it, and an untracked file was in no
diff at all — a new file stayed unchecked until the commit that would
have caught it. Move the check into `tooling/check-comment-length.js`
behind `yarn lint:comments`, reading both forms and every new file, and
exempting JSDoc and the license header. Covered by a unit test.

* test: cover the comment-length checker's command path

Codecov read 12 lines of `tooling/check-comment-length.js` uncovered —
the whole `require.main` body, which no test reached. Split it into
`report` and `main` and drive both over a throwaway git repository,
tracked file and untracked alike, plus the command itself end to end.

* fix: read the comment-length repository without changing directory

`runtimes (bun)` failed all seven repository tests with `process.chdir()
is not supported in workers` — jest runs them in a worker there, and the
newest Bun refuses the call. Take the repository as an argument through
`report` and `main` instead, which also stops the checker depending on
whatever directory it is called from.

---------

Co-authored-by: Claude Opus 5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: config Options, defaults, validation (lib/config, schemas/) area: css CSS support (lib/css) area: tooling Repo tooling, codegen, CLI entry (tooling/, bin/) area: types types.d.ts, JSDoc annotations, hand-maintained declarations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant