Skip to content

css: consolidate the open CSS parser fixes - #47025

Merged
Jarred-Sumner merged 74 commits into
mainfrom
claude/css-consolidated
Oct 27, 2026
Merged

Jarred-Sumner merged 74 commits into
mainfrom
claude/css-consolidated

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Consolidates the open pull requests on the CSS parser (src/css: parsing, minifying, lowering, printing, CSS modules) into one.

179 open pull requests were in scope. Each was judged by one rule: the output must mean to a browser what the input meant. Every claim was run through a build of main, a build with the change, lightningcss 1.33 and Chromium 156 (CSSOM, computed values, painted pixels for colors and gradients), and counted against 10,116 real stylesheets: the published CSS of about 190 popular npm packages, plus stylesheets from open source repositories. 107 land here, whole or in part, most of them cut down. The source diff is net negative.

Of the 10,116, 9,509 are stylesheets that someone ships. The rest are test fixtures of other tools (521), Tailwind v4 source that needs its plugin (59) and SCSS, PostCSS or template source (27).

9,509 shipped stylesheets main this branch
fail to build 71 35
... of those, valid CSS that Bun rejects 0
minified output reads differently in Chromium than the input 552 2
CSS modules with a name that is referenced but not defined, or exported but not defined 166 0
all 10,116 main this branch
fail to build 192 141
minified output reads differently in Chromium than the input 593 15
build on main, fail here 0
right on main, wrong here (minified, and at the default targets) 0
size of the minified output, no targets 162.32 MB 162.47 MB (+0.093%)
size of the output at the default targets 224.1 MB 210.2 MB (-6.2%)

A syntax error is a build error, with one exception: the IE star hack *zoom: 1 is skipped, because tachyons and handsontable ship it. The 35 shipped stylesheets that fail are invalid CSS: 28 have a selector that Chromium drops (semantic-ui-css, swiper, @ionic/core, Adobe Spectrum, xp.css), 2 an @import after other rules, and one each @supports not X and Y, a nested @keyframes, a stray comma, a selector list in ::slotted(), @media screen\0 (govuk-frontend). The 2 that read differently fold a color-mix() inside a gradient (see the known gaps).

The growth of the minified output is rules and declarations that main deleted by mistake: 87 KB of rules, 45 KB of prefixed declarations that only browser targets can rule out.

Popular CSS that main breaks today

Behavior changes to look at first

  1. light-dark() is kept as written at the default browser targets (css: keep light-dark() as written under the default browser targets #42009). The lowered form var(--buncss-light, a) var(--buncss-dark, b) is invalid unless a compiled color-scheme covers the element: 184 of the 215 real stylesheets that get it never define the variable, so every browser drops the declaration. The docs section on the polyfill is removed.
  2. border is not built from longhands (css: only synthesize border / transition when the rule sets every longhand they reset #42072): border-width:0;border-style:solid;border-color:red stays three declarations, because border also resets border-image. 305 real stylesheets grow by 30 KB in total; bootstrap.min.css by 113 bytes, 5 gzipped. No real stylesheet was found where the old merge shows, so this is the one to drop if size matters more. 14 existing expectations change.
  3. A CSS module no longer exports names that only occur inside :global(...) (css modules: decide local vs global at parse, fixing :global() exports and & inside :global() #45487). 73 real modules lose 202 export keys, all of them names that no rule has. import { g } or composes: g of such a name becomes a build error.
  4. Container names and @counter-style names stay global in a CSS module (fix(css): keep container names global in CSS modules #45247 by @mikamikasuki). main hashes the @container name but not container-name, so a named query never matches. postcss-modules (Vite, css-loader, Next.js) keeps them global; lightningcss and esbuild hash both. Global keeps a container that is queried from another file or from JS working.
  5. Syntax errors are build errors, also two that main lets through: a media query that stops short (@media screen and {, @media not {, @import "a.css" screen and;) and @media (width <) foo {. No stylesheet of the 10,116 has either. A condition that is valid by the grammar though no browser knows it, ( ... ) or fn( ... ), is kept as written (css: keep <general-enclosed> media and container conditions as written #46756): (min-width:0\0), (not(hover)).
  6. Declarations after a nested rule stay where they were written, as a nested declarations rule (css: keep declarations written after a nested rule in place with a nested declarations rule #45462). Changes the output of 199 real stylesheets; in a fuzz of 2,850 nested programs compared by computed style, main was wrong in 1,056 and the pull request's code, before it was trimmed here, in none.
  7. Bun.color: color() inputs are converted instead of returned as a string (Bun.color("color(srgb 1 0 0)", "hex") was "color(srgb 1 0 0)"); currentColor, system colors and light-dark() give null in the converting formats instead of the keyword as a string; a relative color or color-mix() outside sRGB is clipped, not gamut-mapped (hsl(from oklch(100% .399 336.3) h s l) is #ff5bff, was #fff).
  8. The :-moz-any() and :-webkit-any() copies of rules with :is() are gone at the default targets (css: stop printing the :-moz-any() copy for Firefox 78, and regenerate the vendor prefix table from one caniuse-lite copy #45945, css: stop emitting :-webkit-any() copies of :is() that outrank the original rule #42029). The :-webkit-any() copy outranked the original rule: .title{color:blue} :is(h1,h2,h3){color:red} painted red. 5.8% of all default-target output was the :-moz-any() copies.
  9. Property grows from 216 to 224 bytes: Url stores its text as a slice so that two url() values can be compared (css: compare url() values by their text #46895). The pull request packed a pointer and a u32 behind unsafe to avoid that.

Not in this PR, needs a decision

Changes

Rules: duplicates, merging, nesting, fallbacks

Properties and shorthands

At-rules

Selectors

Colors and Bun.color

calc() and numbers

CSS modules

Tokens, url(), diagnostics

Speed

Four inputs that took quadratic or exponential time, or produced output out of proportion, are now linear, with no new limit and no extra pass (#31639, #42243, #38531, #43421).

Tests only

No pull request covered these

  • --v: 5.0.1 printed as 5.1, --w: 1.0.0 as 10, --x: 1+2 as 12, 2n+1 as 2n1: a number token that touches another keeps its source text.
  • color-scheme: auto printed as normal, and color-scheme: dark light as light dark.
  • A bare :global or :local in a CSS module sets the scope of the names after it, up to the end of the selector, as in css-loader. It was a build error.
  • The scrollbar states (a:horizontal, ::-webkit-scrollbar{&:horizontal{}}) are valid in a plain compound, as in WebKit and Blink.
  • Without browser targets, -webkit-box-align, -ms-flex-align and the like were dropped next to their standard form (flatpickr, foundation-sites, materialize-css: 28 stylesheets), and -webkit-fill-available before -moz-available (beercss).
  • (-o-min-device-pixel-ratio: 2/1) printed as 2.
  • sin(), cos() and tan() are exact at multiples of 90deg: calc(100px * cos(90deg)) printed -.00000437114px.
  • A display-p3 color at targets that have P3 but not the other color() spaces (Safari 10.1 to 14) got itself as a fallback.
  • width: rem(-18px, 5px) folds to a negative literal once rem() is right; it prints 0.
  • Consecutive nested declarations rules, which arise when same-query @media blocks in one rule merge, are folded into one and count against the existing selector expansion limit as the &{} rules they replace did.

Known and not fixed here

  • Four longhands are still folded into a transition the author did not write where the targets add a prefixed copy of a property (mask-image at Chrome 80): only the shorthand gives the copy its own times.
  • A later background, box-shadow or border-* declaration still loses to the @supports copy of an earlier var() value of the same property with a wide-gamut color. Same on main; css: drop @supports color fallbacks that a later declaration of the same property replaces #45560 is taken for the properties of the fallback handler and for unparsed values only.
  • A merged rule prints the unprefixed name in the passes of the rule it absorbed: .d::-webkit-input-placeholder{}.d::-moz-placeholder{} prints ::placeholder for the second (css: print explicitly prefixed selectors as written in every vendor prefix pass #42106, not taken: it breaks the merge of prefix-equivalent rules).
  • The border, outline and font shorthands, line-height, scroll-padding, background-size and a few more still print a calc() that folds below zero as a negative literal. No real stylesheet has one.
  • all is not a property Bun knows, so a{margin-top:1px;all:unset} prints all first. Same on main.
  • A selector kept as raw tokens loses the blank before * when minified: --sel: .dark * prints .dark*. Same on main.
  • width:calc(0% + 12px) folds to 12px, which lays out differently in a table cell. Same on main.
  • The :dir() / :lang() rules of a lowered logical property print before the nested rules of their rule, so a logical declaration written after a nested rule of equal specificity still loses to it. Same on main.
  • A fraction is still rounded to 6 significant digits (12345.678 prints 12345.7, 33.3333333333% prints 33.3333%). Keeping every digit would add up to 282 KB over the corpus.
  • @keyframes "quoted" is hashed in a module but not exported.
  • In a module, a keyframes name inside a var() fallback is not hashed: animation: var(--enter, fade 1s). Same on main.
  • A color-mix() or relative color folded to a hex inside a gradient changes the interpolation from Oklab to sRGB (VS Code, clawhub). Printing color(srgb ..) instead needs a fallback for mask-image, list-style-image and custom properties at the default targets, which have none.
  • A comma list in @container still fails the build.
  • After a bare :global, names that are hashed at print time (::view-transition-old(foo)) are still hashed; inside :global(...) they are not.

Pull requests in scope that do not land here

Out of scope: CSS linking and chunking in the bundler, the dev server, docs-only pull requests, and repository-wide sweeps that touch src/css in passing.

Thanks to @mikamikasuki (#45247), @alii (#39520, #31999) and @oddharsh, whose measurements on #40369 and #41990 were used.

Fixes #16727.
Fixes #24770.
Fixes #30772.
Fixes #44704.
Fixes #45089.
Fixes #45237.
Fixes #46872.

How did you verify your code works?

  • The 35 CSS test files pass on a debug build, also with FORCE_COLOR=1. 600+ of the tests in this branch fail on a build of main. An earlier head of this branch passed all 179 CI jobs that ran.
  • The reproduction of each issue listed above was run on main (wrong) and on this branch (right).
  • Differential run over the 10,116 stylesheets, main against this branch, minified without targets and through Bun.build at the default targets: no stylesheet that builds on main fails; Chromium 156 compares the output with the input rule by rule (CSSOM, then computed values on a sized element in a standards-mode document, colors as painted pixels in sRGB and P3). The comparison has 195 control pairs that must read equal or different. For stylesheets that read differently on both builds, mostly because of lowering, the sets of differences were compared; the three new ones are two minifier fixtures that are wrong on both and one comparison artifact.
  • CSS modules are checked for consistency instead, since hashed names defeat the comparison: every reference points at a name the output defines, every export exists.
  • Bun.color: 59 inputs in 13 formats on both builds; every changed cell was read.

…ate the vendor prefix table from one caniuse-lite copy (#45945)
… rules, and never print a bare `:full-screen` (#43339)
robobun added a commit that referenced this pull request Oct 27, 2026
#47025 prints the vendor prefix on a downleveled `:autofill`, so the
vendor prefix section no longer lists it as missing.
robobun added a commit that referenced this pull request Oct 28, 2026
… names a channel

A relative rgb() or hsl() whose alpha holds var() or env() is printed
with computed channels and without its origin:

  rgb(from #336699 r g b / var(--o))  ->  rgb(51 102 153/var(--o))

A channel keyword in that alpha has a meaning only after `from`. The
printed value is then invalid at computed-value time once the variable
has a value, and an earlier declaration of the property does not apply:

  :root { --m: .3 }
  rgb(from teal r g b / calc(alpha * var(--m)))
  ->  rgb(0 128 128/calc(alpha*var(--m)))

As a background-color, Chrome 153 computes
`color(srgb 0 0.501961 0.501961 / 0.3)` for the first and
`rgba(0, 0, 0, 0)` for the second.

UnresolvedColor::parse now reads the alpha, nested blocks included,
before it buffers it. When an identifier there is a channel keyword of
the function, or `alpha`, the parse fails, and the function stays as
tokens with its origin. The check reads names: a relative color nested
in the alpha has `r g b` of its own, and the outer function then stays
as written too.

This is the third commit of #45362 (4ca1f11), ported to main after
#47025.
robobun added a commit that referenced this pull request Oct 29, 2026
…efix

The default browser targets keep light-dark() as written since #47025,
so the polyfill no longer tells a stylesheet built for browsers from one
built for the runtime. Nesting and media range syntax show the targets a
stylesheet was printed for. The :-webkit-full-screen copy of a
:fullscreen rule shows the targets it was minified for.

The server copy of a shared stylesheet is now asserted without a
condition: the server build minifies and prints it for the runtime.
khromov added a commit to khromov/mochi that referenced this pull request Oct 30, 2026
… race) (#375)

Fixes the two Bun-canary test failures in `test.yml`. Both are test-only
changes; no framework code changes.

## 1. `cssFontImports.test.ts` — consistent canary failure

**Root cause: an intentional Bun output change, not a Bun bug or a mochi
bug.** oven-sh/bun#47025 ("css: consolidate the open CSS parser fixes",
commit `794200868`, between 1.4.4 `38afee6c8` and canary `da867c60b`)
now prints:

- "The path of a copied asset spliced into `url()` is always quoted, so
a file name with a space or a parenthesis cannot end the `url()`"
(#41557)
- "@font-face format() keywords as strings" (#37979)

The test extracted the asset name with `/url\(\.\/([^)"']+\.png)\)/`,
which only matches the unquoted form. On canary, every sheet returned
`undefined`, so `names` was `{undefined}`. Its size of 1 passed, and
`toMatch` threw `Received value must be a string: undefined`.

Standalone repro (no mochi): `Bun.build({ entrypoints: ['a.css'],
minify: true })` on `.a{background:url("./shared.png")}` with a 200 KB
png:

```
1.4.4 38afee6c8  .a{background:url(./shared-fpg4xa8b.png)}…format(woff2)
1.4.4 da867c60b  .a{background:url("./shared-fpg4xa8b.png")}…format("woff2")
```

Production code is unaffected, because `cssFontAssets.ts` reads `url()`
and `format()` through the css-tree AST (`parseCss` in `cssAst.ts`),
which decodes quoting.

**Fix (after review): the tests now read bundled CSS the same way.**
`cssFontImports.test.ts` gains three test-local helpers:

- `parsed()` wraps `parseCss` and throws a clear error on a `null`
result.
- `urlValues()` returns the decoded `url()` values.
- `relativePng()` returns the first non-`data:` `.png` url containing a
given stem.

Converted from regex extraction to the AST, each kept at least as
strict:

- `:98` checks the served woff2 URL and its `format()` on the **same**
`src` entry, where the old regex only required them to be adjacent text.
- `:105` checks that no font source has `format` `woff`, guarded against
an empty source list.
- `:302` checks that no relative (non-`/_mochi/fonts/`) `.woff2` url is
left.
- `:388`, `:401`, `:407`, `:460`, `:487` read the relative image urls.
- `:550` is the canonical-name test. Each sheet must contribute exactly
one `./`-relative `.png` url, otherwise it adds `undefined` to the set.
There must be exactly one name, and it must match
`^shared-[a-z0-9]{8}\.png$`.

Left as text checks on purpose:

- `toContain`/`not.toContain` presence checks on URLs and `data:`
payloads (`:108`, `:113`, `:192`, `:202`, `:233`, `:299-301`,
`:312-313`, `:323-324`, `:400`, `:459`, `:543`). They don't extract a
value, they don't depend on quoting, and most match mochi's own
substituted text.
- `cssImports.test.ts:57-59` asserts the raw minified text.
- `cssImports.test.ts:123-124` asserts the serialized quoting of
`format('woff2-variations')` itself. The AST's `format` lowercases both
the string and the identifier form, so it cannot tell them apart.
- All of `cssFontAssets.test.ts` checks mochi's own
`substituteFontUrls`/`classifyFontAssets` output on hand-written input,
with no Bun involved.

No production change: `parseCss` already exposes everything the tests
needed.

Mutation checks on the converted file, with production code broken
temporarily and then restored:

| Mutation | Result |
|---|---|
| Keep the per-build suffix (`canonical = assetPath`) | 10 fail,
including the canonical-name test |
| Stop dropping legacy woff | `drops the legacy woff source entirely`
fails |
| Make `substituteFontUrls` write a wrong URL | `rewrites the large
woff2…` fails |

| | before | after |
|---|---|---|
| stable 1.4.4 (38afee6c8) | 22/22 | 22/22 |
| canary 1.4.4-canary.1 (da867c60b) | 21 pass, 1 fail | 22/22 |

## 2. `cache-storage-file.test.ts` — probable flake

**I could not reproduce it.** I ran it 0/320 failures: 100 runs each on
stable and canary at idle, and 60 runs each with 24 busy loops on 12
cores plus sync `dd` I/O. Reading the test does show a real timing
assumption, though. `waitForMarker` polled for at most 250 × (getItem +
2 ms), which is about 530 ms of wall time. After `release()`, A must do
a durable `setItem` (fsync + rename) and then remove the marker. Locally
that takes about 2 ms. This file's own `LEASE` comment records CI runs
of 400–1300 ms per test.

Injecting an 800 ms delay into A's `setItem` reproduces the exact CI
error and stack: `marker for "k" never became absent` from
`waitForMarker`, called from the line after `release()`.

The fix replaces the fixed-budget polling with deterministic
synchronization:

- **Marker present:** wait until `fn` is entered (`run()` awaits
`writeMarker` before calling `fn`), then assert that the marker exists.
- **Marker absent:** `await cacheA.whenIdle()` (it settles only after
the run's `finally { await removeMarker }`), or `await second` in the
superseded-run test. Then assert once that the marker is `null`.

These checks are at least as strict as before: the marker must be gone
the moment the run settles, with no grace window. With the 800 ms
injection, the new test passes. With `removeMarker` mutated out, the
cross-process tests still fail. `waitForMarker` is deleted because
nothing uses it now.

## Verification

- `bun run checks` (stable 1.4.4): pass. `mochi-framework` passed
204/204 test files.
- `bun run build` (stable 1.4.4): pass.
- `cssFontImports.test.ts` + `cssImports.test.ts`: 31/31 on stable and
on canary.
- After the AST conversion: `cssFontImports.test.ts` 22/22 on stable
1.4.4 and on canary 1.4.4-canary.1 (da867c60b). `bun run checks`
(stable): pass.
- `cache-storage-file.test.ts`: 37/37 on stable and on canary. The
cross-process block also passed 40/40 on each version under CPU load.

No upstream issue is needed: the Bun change is intentional and valid
CSS.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment