Skip to content

css: keep an unparsed width/height/color after the declarations written before it - #42101

Closed
robobun wants to merge 7 commits into
mainfrom
robobun/62dc47ef/css-unparsed-decl-order
Closed

robobun wants to merge 7 commits into
mainfrom
robobun/62dc47ef/css-unparsed-decl-order

Conversation

@robobun

@robobun robobun commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build prints .a{width:100px;width:var(--w)} as .a{width:var(--w);width:100px}, so 100px wins in every browser. Same for inherit and calc-size(), on all 12 size properties, with or without --minify.
  • Cause: SizeHandler (src/css/properties/size.rs) pushes a Property::Unparsed size while earlier sizes are still buffered. lightningcss added the flush in parcel-bundler/lightningcss@22a8b6f. Bun's port predates it.
  • color, text-shadow and the logical corner radii overwrite the earlier declaration instead: .a{color:red;color:contrast-color(blue)} prints .a{color:contrast-color(blue)}.

Fix

  • SizeHandler flushes its buffer before an unparsed size.
  • FallbackHandler appends an unparsed color/text-shadow, and BorderRadiusHandler flushes a buffered logical corner first. A later parsed value still replaces an earlier one.
  • Verified: test/js/bun/css/css.test.ts, block a value that does not parse keeps the declaration before it. It checks all 223 typed properties. 20 of 24 tests fail on 1.4.2 and main.

Background

  • Each declaration block goes through per-family handlers that buffer values to merge them, and write them on flush.
  • A value the typed parser rejects (var(), a CSS-wide keyword, an unknown function) stays Property::Unparsed. A browser can drop it, so the declaration before it must stay.
  • Considered keeping the overwrite for color when the later value holds var(). Every other handler keeps both.

Downsides

  • color:red;color:var(--c) now keeps color:red (10 bytes), dead in browsers that support var().
  • A size written again after an unparsed size no longer merges with the first one.
  • Measured on the 15 stylesheets in test/js/bun/css/files: 2,732,962 bytes before and after, gzip -9 is 3 bytes smaller.
Notes

[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 21 failed, 69 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/css/css.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/css/css.test.ts:
Output .flexrow {
  flex-direction: row;
}

.flexcol {
  flex-direction: column;
}

.hello {
  flex-wrap: wrap;
}

.world {
  flex-wrap: nowrap;
}

(pass) css tests > .flexrow {
	flex-direction: row;
}

.flexcol {
	flex-direction: column;
}

.hello {
	flex-wrap: wrap;
}

.world {
	flex-wrap: nowrap;
} [17.74ms]
Output .foo {
  content: "+";
}

(pass) css tests > .foo {
  content: "\2b";
} [1.97ms]
Output div {
  --foo: 1 1 1 #0101011a, 2 2 2 #02020233;
  --bar: 1 1 1 #01010116, 2 2 2 #02020233;
}

(pass) css tests > custom property cases > div {
        --foo: 1 1 1 rgba(1, 1, 1, 0.1), 2 2 2 rgba(2, 2, 2, 0.2);
        --bar: 1 1 1 #01010116, 2 2 2 #02020233;
      } [2.68ms]
Output :root {
  --my-color: red;
  --my-bg: white;
  --my-font-size: 16px;
}

.element {
  color: var(--my-color);
  background-color: var(--my-bg);
  font-size: var(--my-font-size);
}

(pass) css tests > custom property cases > :root {
        --my-color: red;
        --my-bg: w
... (truncated)

release without fix: 21 failed, 69 skipped
bun test v1.4.3-canary.1 (367d939d9)

test/js/bun/css/css.test.ts:
Output .flexrow {
  flex-direction: row;
}

.flexcol {
  flex-direction: column;
}

.hello {
  flex-wrap: wrap;
}

.world {
  flex-wrap: nowrap;
}

(pass) css tests > .flexrow {
	flex-direction: row;
}

.flexcol {
	flex-direction: column;
}

.hello {
	flex-wrap: wrap;
}

.world {
	flex-wrap: nowrap;
} [0.51ms]
Output .foo {
  content: "+";
}

(pass) css tests > .foo {
  content: "\2b";
} [0.15ms]
Output div {
  --foo: 1 1 1 #0101011a, 2 2 2 #02020233;
  --bar: 1 1 1 #01010116, 2 2 2 #02020233;
}

(pass) css tests > custom property cases > div {
        --foo: 1 1 1 rgba(1, 1, 1, 0.1), 2 2 2 rgba(2, 2, 2, 0.2);
        --bar: 1 1 1 #01010116, 2 2 2 #02020233;
      } [0.15ms]
Output :root {
  --my-color: red;
  --my-bg: white;
  --my-font-size: 16px;
}

.element {
  color: var(--my-color);
  background-color: var(--my-bg);
  font-size: var(--my-font-size);
}

(pass) css tests > custom property cases > :root {
        --my-color: red;
        --my-bg: white;
        --my-font-size: 16px;
      }
      .element {
        color: var(--my-color);
        background-color: var(--my-bg);
        font-size: 
... (truncated)
passes on PR (with fix)
ASAN with fix: 69 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/css/css.test.ts
bun test v1.4.4 (367d939d9)

test/js/bun/css/css.test.ts:
Output .flexrow {
  flex-direction: row;
}

.flexcol {
  flex-direction: column;
}

.hello {
  flex-wrap: wrap;
}

.world {
  flex-wrap: nowrap;
}

(pass) css tests > .flexrow {
	flex-direction: row;
}

.flexcol {
	flex-direction: column;
}

.hello {
	flex-wrap: wrap;
}

.world {
	flex-wrap: nowrap;
} [8.48ms]
Output .foo {
  content: "+";
}

(pass) css tests > .foo {
  content: "\2b";
} [3.53ms]
Output div {
  --foo: 1 1 1 #0101011a, 2 2 2 #02020233;
  --bar: 1 1 1 #01010116, 2 2 2 #02020233;
}

(pass) css tests > custom property cases > div {
        --foo: 1 1 1 rgba(1, 1, 1, 0.1), 2 2 2 rgba(2, 2, 2, 0.2);
        --bar: 1 1 1 #01010116, 2 2 2 #02020233;
      } [23.90ms]
Output :root {
  --my-color: red;
  --my-bg: white;
  --my-font-size: 16px;
}

.element {
  color: var(--my-color);
  background-color: var(--my-bg);
  font-size: var(--my-font-size);
}

(pass) css tests > custom property cases > :root {
        --my-color: red;
        --my-bg: w
... (truncated)

release with fix: 69 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     a1e16bd132
  features     lto, baseline

23 deps, 136 codegen, 1176 objects in 1986ms

ninja: Entering directory `/workspace/bun/build/release'
[1/4] fetch lolhtml
[lolhtml] up to date
[2/4] fetch rust-argon2
[rust-argon2] up to date
[2/4] cargo plan → /workspace/bun/build/release/rust-target/plan.json
247 units: 175 lib, 16 proc-macro (host), 19 custom-build (host), 15 run custom-build, 17 lib (host), 4 run custom-build (host), 1 rlib
[3/4] reconfigure
[1/1502] mkdir stamps
[2/1502] mkdir codegen
[3/1502] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[4/1502] gen string-map defines_table
[5/1502] gen ErrorCode+*.h
[6/1502] install /workspace/bun
bun install v1.4.3-canary.1 (367d939d9)

Checked 26 installs across 65 packages (no changes) [1.84s]
[7/1502] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (367d939d9)

Checked 1 install across 2 packages (no changes) [41.00ms]
[8/1502] rustc unicode_xid 
[9/1502] rustc unicode_iden
... (truncated)
diff hotspot
src/css/properties/border_radius.rs  |   8 +-
 src/css/properties/prefix_handler.rs |   9 +-
 src/css/properties/size.rs           |   2 +
 test/js/bun/css/css.test.ts          | 156 ++++++++++++++++++++++++++++++++++-
 4 files changed, 166 insertions(+), 9 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/css/properties/border_radius.rs       1      1     51
src/css/properties/prefix_handler.rs      1      1     51
src/css/properties/size.rs                5      4     50
test/js/bun/css/css.test.ts               8     10     48

…en before it

The size handler pushed an unparsed width/height/min-*/max-*/block-size/
inline-size declaration (one holding var(), env(), an unknown function or
an invalid value) to the output without flushing the sizes it had
buffered, so .a{width:100px;width:var(--w)} came out as
.a{width:var(--w);width:100px} and the fallback won. lightningcss
flushes first (parcel-bundler/lightningcss#547); the call was lost in
the port.

The color/text-shadow fallback handler replaced the previous declaration
with a later unparsed one, so .a{color:red;color:banana} printed
.a{color:banana}. Browsers drop a value they cannot parse and apply the
one before it, so the unparsed value is now appended instead; a later
parsed value still replaces it.
@robobun

robobun commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:27 PM PT - Oct 10th, 2026

✅ @robobun, your commit a1e16bd132fd3c517a0626972b75e81ea6e25ccc passed in Build #124416! 🎉


🧪   To try this PR locally:

bunx bun-pr 42101

That installs a local version of the PR into your bun-42101 executable, so you can run:

bun-42101 --bun

@robobun

robobun commented Sep 9, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on Bun 1.4.2 and on a build of main (367d939d9) with bun build, with and without --minify:

  • .a{width:100px;width:var(--w)} prints .a{width:var(--w);width:100px}. The same happens for inherit, calc-size() and anchor-size(), and for height and the min-/max- forms.
  • .a{color:red;color:contrast-color(blue)} prints .a{color:contrast-color(blue)}.

The css.test.ts block a value that does not parse keeps the declaration before it fails there (20 of 24 tests) and passes with this branch. The branch is merged with current main.

#42104 builds on this branch.

@coderabbitai

coderabbitai Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: 047c9456-1780-4d12-93dd-d2f77c02b0bd



📥 Commits

Reviewing files that changed from the base of the PR and between 898c856 and a1e16bd.




📒 Files selected for processing (1)
  • test/js/bun/css/css.test.ts



Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.





Walkthrough

CSS property handlers now preserve earlier declarations when later values are unparsed. Size and logical border-radius handlers flush pending state before emitting unparsed declarations. Tests cover declaration ordering, replacement, and prefixing.

Changes

CSS declaration ordering

Layer / File(s) Summary
Preserve unparsed declaration order
src/css/properties/prefix_handler.rs, src/css/properties/size.rs, src/css/properties/border_radius.rs
Unparsed declarations are appended without replacing earlier declarations. Size and logical border-radius handlers flush pending state before emitting unparsed properties.
Validate minifier and prefixer ordering
test/js/bun/css/css.test.ts
Tests cover parsed declarations followed by invalid or unparsed values, replacement by later valid values, logical and physical border-radius values, and Safari inline-size prefixing.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to a1e16

The change preserves earlier CSS declarations when later values are unparsed, and the remaining logical-corner ordering difference does not alter rendered styles. No material merge risk remains beyond normal checks.

Pre-merge checks | Passed 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly summarizes the main CSS declaration-order fix and identifies the affected properties.
Description check Passed The description is detailed and covers the problem, fix, verification results, scope, and tradeoffs. It uses different headings from the template but includes the required information.


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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/css/properties/border_radius.rs — Pre-existing, same bug class this PR fixes for size/color: logical_property_helper! overwrites the buffered slot without flushing when an unparsed logical corner follows a parsed one, so .a{border-start-start-radius:1px;border-start-start-radius:var(--r)} still drops the 1px fallback (all four border-{start,end}-{start,end}-radius are affected; physical corners already flush at line 407). Fix: guard the overwrite the way margin_padding.rs:808 does — flush when the slot is already Some and the incoming value is Property::Unparsed, so the parsed fallback is emitted before the unparsed one.

    Extended reasoning...

    BorderRadiusHandler::handle_property routes an Unparsed whose property_id is BorderStartStartRadius (etc.) into logical_property_helper! (border_radius.rs:370-404). That macro (lines 211-222) only flushes on a category change, then does $self.$prop = Some($val), overwriting whatever was buffered. So border-start-start-radius: 1px buffers start_start = Some(BorderStartStartRadius(1px)); the following border-start-start-radius: var(--r) buffers start_start = Some(Unparsed(...)), discarding 1px; flush then emits only var(--r). Browsers that resolve --r to something invalid (or leave it undefined) get unset instead of the authored 1px fallback — identical user-visible failure to the size/color cases this PR fixes. The sibling margin_padding.rs handler already guards this exact case at line 808 (self.logical_slot_is_some(field) && matches!(val, Property::Unparsed(_)) → flush), and REVIEW.md asks for the whole class to be fixed in one PR. Base behaves the same (pre-existing); reporting because the PR is a partial fix for this class and its test block…

    Verification: pre-existing — the trace holds. In /home/claude/bun/src/css/properties/border_radius.rs, logical_property_helper! (lines 211-222) only flushes on a category change (if $self.category != PropertyCategory::Logical), then unconditionally does $self.$prop = Some($val) at line 219. A parsed Property::BorderStartStartRadius(1px) (lines 297-305) buffers into start_start and sets `category =…

…he same corner

border-start-start-radius and the other three logical corners buffered a
later unparsed value over the parsed one instead of flushing it first, so
.a{border-start-start-radius:1px;border-start-start-radius:var(--r)}
lost the 1px. The margin/padding/inset handler already flushes in this
case; do the same here.
Comment thread src/css/properties/border_radius.rs Outdated
Comment thread src/css/properties/prefix_handler.rs Outdated
@robobun

robobun commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

The logical corner radii had the same overwrite, confirmed: .a{border-start-start-radius:1px;border-start-start-radius:var(--r)} printed only the var(). 6ce4700 makes BorderRadiusHandler flush a buffered logical corner before an unparsed one (the guard the margin/padding/inset handler already has) and adds rows for it to the same test block.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — the border-radius follow-up and comment trims look good.

Traced FallbackHandler's append-not-replace against color:red;color:banana;color:blue and color:banana;color:red — the index update to dest.len() lets a later parsed value overwrite the unparsed slot, so both dedup directions still work. The size.rs flush calls sit before flushed_properties.insert, so buffered physical/logical sizes emit ahead of the unparsed value without double-emission. The .rounded-full snapshot reorder is a real consequence of flushing width before the unparsed height:infinity, not a loosened assertion. The border_radius.rs $val → local val binding avoids double-evaluating the macro argument now that it's read twice.

Extended reasoning...

Overview

This PR fixes CSS-minifier declaration-ordering bugs across three property handlers. SizeHandler (src/css/properties/size.rs) now flushes buffered width/height/min-/max- before emitting an unparsed size, in both the physical match arm and logical_unparsed_helper!. FallbackHandler (src/css/properties/prefix_handler.rs) now appends an unparsed color/text-shadow and updates the tracked index to the new slot, instead of overwriting dest[i]. BorderRadiusHandler's logical_property_helper! (src/css/properties/border_radius.rs) now flushes when the target logical-corner slot is already occupied and the incoming value is Property::Unparsed, mirroring what the margin/padding/inset handler already does. Eighteen new minify_test/prefix_test rows in test/js/bun/css/css.test.ts cover color, text-shadow, six size properties, and both logical and physical border-radius corners, plus one existing snapshot whose property order changes as a direct result of the size flush.

Security risks

None. This is output-ordering logic in the CSS minifier operating on already-parsed declaration lists. No untrusted-length arithmetic, no allocation sizing from external input, no filesystem or network access, no auth/crypto surfaces. The worst failure mode is a suboptimal or reordered stylesheet.

Level of scrutiny

Moderate — the semantics ("browsers drop a declaration they cannot parse, so the earlier one is the fallback") are subtle, but the fix is mechanical: two flush() insertions, one replace→append, and one extra flush condition. I hand-traced the FallbackHandler change through the sequences the tests assert (red;banana, red;banana;blue, banana;red, red;var(--c), var(--c);red) and the index bookkeeping produces the expected output in each, including the dedup-from-behind case where a later parsed value overwrites the unparsed slot at the recorded index. The border_radius.rs change correctly binds $val to a local before the two uses, avoiding double evaluation of the macro argument. The .rounded-full snapshot change was checked against the input: height: infinity is unparsed (bare infinity outside calc()), so the new flush emits the buffered width first — the new order is source-order-preserving, not a weakened assertion.

Other factors

Since the earlier review on this PR, two commits landed: one adding the logical border-radius fix (bringing that handler in line with the size and margin handlers — "fix the whole class") and one shortening the two added comments to single lines per the repo convention. The inline bot threads at those lines are plausibly addressed by that commit. No CODEOWNERS entry covers src/css/. The PR cites parcel-bundler/lightningcss#547 as the upstream reference for the size-handler flush, satisfying the "reference implementation is the spec" rule for ported code. Test coverage spans the variant matrix (physical + logical, parsed-before-unparsed and unparsed-before-parsed, var()/env()/unknown-function/invalid-keyword) and lives in the existing test file alongside related coverage.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

LGTM — the three handler changes converge on the flush-before-unparsed shape every other property handler in src/css/properties already uses, and the generated matrix test exercises all of them through the real minifier output.

Extended reasoning...

The Rust side is four lines: SizeHandler flushes its buffer before emitting an unparsed size (physical arm and logical_unparsed_helper!, matching lightningcss#547), FallbackHandler appends an unparsed color/text-shadow instead of overwriting the recorded slot (the index field is still read by the parsed branch at prefix_handler.rs:57, so a later parsed value still replaces it), and the logical border-radius macro flushes a buffered corner before an unparsed one, copying the existing margin_padding.rs:808 pattern. I grepped the Unparsed arms of background, box_shadow, align, font, flex, transform, transition, border and border_image — all already flush first, so size.rs was the only outlier and the bug class is covered. The matrix test is live: minifyTestWithOptions in src/css_jsc/css_internals.rs returns the minified code, which is compared against the sources. The .rounded-full expectation change is a genuine consequence (width is now flushed ahead of the unparsed height; border-radius stays buffered in its own handler). No security-sensitive surface, no CODEOWNERS entry for src/css, and the single inline finding is a pre-existing comment-accuracy nit on the color-scheme exclusion.

Comment thread test/js/bun/css/css.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

@robobun
robobun requested a review from Jarred-Sumner October 14, 2026 21:45
robobun added a commit that referenced this pull request Oct 18, 2026
The `@supports` rules behind a style rule kept the next rule from a
merge into it. That merge moves `all` and unparsed values in front of
the declarations they follow (#42042, #42101), and a merge of two rules
with equal declarations takes the selector away from the copies of the
second rule. An ignored rule now follows a rule that lost each copy, so
the next rule stays a rule of its own, as before.
Jarred-Sumner added a commit that referenced this pull request Oct 27, 2026
### 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

- normalize.css, modern-normalize, sanitize.css: `font-family:
monospace, monospace` is collapsed to `monospace`, which changes the
font size from 16px to 13px (#46662).
- CKEditor 5, Tabler, Carbon, VitePress, Ionic, PatternFly, Excalidraw:
a rule of custom properties is deleted as a "duplicate" of a later rule
with the same selector and other custom properties. 37 stylesheets lose
1,681 custom properties (#33684).
- Material Components, daisyUI, DocSearch, plyr: a `var()` size loses to
its own fallback (#42101).
- nextra, daisyUI, CKEditor: declarations written after a nested rule
are moved above it (#45462).
- antd 4, purecss, govuk-frontend: browser hacks in selector lists start
to apply everywhere (#42100).
- foundation-sites, flatpickr, 98.css, blueprintjs, scalar,
react-datepicker: the build fails on valid CSS (#46756, #38552, #38534).
tachyons, handsontable: it fails on the IE star hack (#46751).
- nodejs.org, Mantine: a CSS module exports classes that no rule
defines, because the `@apply` or `@mixin` in them is moved out of the
rule (#46759).
- hint.css and 42 more: `z-index: 1000001` and `1000000` become the same
number (#46715).
- Adobe Spectrum: `--version: 5.0.1` prints as `5.1`.
- A quarter of all stylesheets: minified output has a stray space in
`align-items: center` (#41993).

#### Behavior changes to look at first

1. **`light-dark()` is kept as written at 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** (#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(...)`** (#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** (#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 (#46756): `(min-width:0\0)`, `(not(hover))`.
6. **Declarations after a nested rule stay where they were written**, as
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 (#45945, #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 (#46895). The pull
request packed a pointer and a `u32` behind `unsafe` to avoid that.

#### Not in this PR, needs a decision

- **Raise the default browser targets (#40369) and add a `cssTarget`
option (#40368).** Today: Chrome 80, Edge 80, Firefox 78, Safari 14,
Opera 67, and no public way to change them. At Vite 8's floor the output
of the 9,924 stylesheets is 15.3% smaller (3.9% gzipped). About a dozen
fixes here only matter at today's floor, and code for older targets is
unreachable without the option.
- Vendor prefixes for `user-select`, `appearance`, `hyphens` and five
more (#45814): 1,116 real stylesheets lack a prefix they need at the
default targets. And for `backdrop-filter` (#44648): 173.
- Clip instead of gamut-map in the sRGB fallback of a plain wide-gamut
color: deletes about 90 lines, changes every pinned fallback hex. If the
mapping stays, #44647 by @oddharsh makes it closer to what browsers
paint (colors off by 2/255 or more: 168 to 110 of 3,170 real ones).

#### Changes

**Rules: duplicates, merging, nesting, fallbacks**

- The `:-moz-any()` copy of every rule with `:is()` or `:not(a, b)` is
gone: Firefox 78 has `:is()` (#45945). 1,164 real stylesheets
- Keep declarations written after a nested rule in place with a nested
declarations rule (#45462). 379 real stylesheets
- With nesting compiled away, an unknown at-rule inside a style rule was
moved out of it: `.wrapper{@apply flex}` printed a bare `@apply flex;`
and the rule was gone, so a CSS module exported a class that no rule
defines. It now stays in a block under the compiled selector. 72 real
modules with Tailwind's `@apply`, 25 with Mantine's `@mixin` (#46759).
316 real stylesheets
- Keep !important in the rules generated for logical properties and
color-scheme (#38597). 311 real stylesheets
- With browser targets, remove an earlier duplicate rule only when its
declaration block is equal (#46861). 171 real stylesheets
- Compare custom property names in `PropertyId` equality and importance
in duplicate-rule detection (#33684). 40 real stylesheets
- Keep same-condition @supports rules and empty named @layer blocks
(#46733). 31 real stylesheets
- Keep target-added vendor prefix passes on cloned and re-minified
rules, and never print a bare `:full-screen` (#43339). 12 real
stylesheets
- Keep the selectors on rules added for color fallbacks and logical
properties (#43240). 3 real stylesheets
- A later declaration of a property drops the `@supports` color copies
of the earlier one, which printed after the rule and won (#45560). 2
real stylesheets
- Do not merge style rules in a stylesheet that holds `revert-rule`
(#46953). 1 real stylesheets
- `@import ... supports(-webkit-mask: none)` kept its prefix (#38589)

**Properties and shorthands**

- Print align-items:center without a stray space when no safe/unsafe
keyword is given (#41993). 3,317 real stylesheets
- Emit the physical longhands instead of the inset shorthand for targets
without it (#38583). 1,452 real stylesheets
- Keep the stops of an upward gradient as written, and drop a `50%` hint
only at the midpoint (#46790). 104 real stylesheets
- Emit unparsed border-block-* declarations when compiling logical
borders (#38582). 89 real stylesheets
- Keep repeated font families and dedupe only the inserted system-ui
fallbacks (#46662). 88 real stylesheets
- A `width` / `height` / `min-*` / `max-*` with `var()` or `env()` was
printed before the plain fallback written above it, so the fallback won
(#42101). 26 real stylesheets
- Keep box-orient and box-direction when flex-direction or flex-flow
follows (#45672). 16 real stylesheets
- Write held prefixed background declarations at every flush (#46882). 6
real stylesheets
- Derive equality for Size2D and BorderRadius, and keep every prefix in
the border-radius handler (#45665). 2 real stylesheets
- Print `none` instead of an empty border-image, mask-border or
mask-box-image value (#45143). 2 real stylesheets
- Keep font/transition sub-properties on the side of the shorthand they
were written on (#41990). 2 real stylesheets
- CSS-wide keywords are not names: `font:12px Arial;font-family:inherit`
printed `font:12px inherit`, `color-scheme:inherit` and
`color-scheme:auto` printed `normal`, `transition-property:unset` was
folded into `transition` (#42022). 2 real stylesheets
- Only fold font longhands into `font` when the rule declared a `font`
(#45459)
- Keep a font family name quoted when its identifiers do not read back
as the same name (#46606)
- Stop rewriting valid positions into ones browsers reject (#45197)
- `border` is no longer built from longhands unless the rule also sets
what `border` resets (`border-image`), and an author's `border-image`
reset is no longer dropped. `transition` is likewise only written where
the rule wrote one, because it resets `transition-behavior` (#42072)
- `border; border-image: <gradient>; border-image-slice: 1` lost the
gradient in every default build (#42018)
- A prefixed `linear-gradient` keeps its direction, written or
generated: the prefixed syntax names the starting side and measures
angles differently (#46697)

**At-rules**

- Print @font-face format() keywords as strings (#37979). 439 real
stylesheets
- Print @supports `not` conditions without a leading space (#38559). 88
real stylesheets
- Reset after the number token of a unicode-range (#45835). 33 real
stylesheets
- Keep `<general-enclosed>` media and container conditions as written
(#46756). 24 real stylesheets
- A strict range such as `(width < 768px)` lowered for old targets still
matched at exactly 768px. The step is now `.02px`. The `not all and`
form for other units is not taken (#46806). 18 real stylesheets
- Keep scanning an @supports condition after a nested block (#38552). 9
real stylesheets
- Every `@counter-style name` printed as `@counter-stylename`, which
browsers drop (also #46692). In a CSS module the name now stays global,
like the `list-style` that refers to it (#33679). 9 real stylesheets
- Keep a keyframe whose block has an invalid item (#46288). 2 real
stylesheets
- Read an @Property initial-value on the stylesheet's parser, keep an
unmatched value as written (#45806). 2 real stylesheets
- `@media {}` is `all`. A media query that stops after `and`, `or` or
`not` (`@media screen and {`) is a build error; `main` printed `not all`
or dropped the condition, silently. The `@layer` and type selector parts
are not taken (#46869). 2 real stylesheets
- Accept a name-only `@container` and a `style()` feature with no value
(#46689). 1 real stylesheets
- Parse `<timeline-range-name> <percentage>` keyframe selectors (#45652)
- Allow `or` inside parentheses after a media type (#45088)
- Print the `=` comparison in media and container queries as `=`
(#45071)
- Parse the @font-palette-values font-family descriptor as a list of
family names (#45484)

**Selectors**

- Never wrap a selector list in :is() (#46787). 414 real stylesheets
- Stop emitting :-webkit-any() copies of :is() that outrank the original
rule (#42029). 373 real stylesheets
- With browser targets, a selector list with a vendor-prefixed member
was split, which revives hacks that rely on other engines dropping the
whole list: `.s *::-ms-backdrop,.s{flex:auto}` applied everywhere.
Rewritten; the list is printed as written (#42100). 42 real stylesheets
- Print the vendor prefix on downleveled :any-link, :read-only,
:read-write, :placeholder-shown and :autofill (#42131). 12 real
stylesheets
- Fix nesting lowering of a `&` that does not lead its selector
(#43047). 8 real stylesheets
- `@scope`: `&` and implicit selectors compile to `:where(:scope)`,
relative selectors and bare declarations parse (#43165). 7 real
stylesheets
- Only reject bare :global / :local pseudo-classes in CSS modules
(#38534). 5 real stylesheets
- Stop truncating `@scope to (...)` and stop replacing `&` in the
scope-end selector (#43146). 2 real stylesheets
- `[xl|href$=".pdf"]` lost its namespace prefix (#45159). 2 real
stylesheets
- Match An+B keywords exactly and take only `+` as the leading sign
(#42953). 1 real stylesheets
- Parse ::details-content and ::picker() like ::part() (#45504). 1 real
stylesheets
- Parse :window-inactive as an ordinary pseudo-class, and accept it
after ::selection (#45815)
- Print :nth-col(1) and :nth-last-col(1) in functional form (#41575)
- `li::before::marker` failed the build (#45810)
- `&` inside `:nth-child(... of S)` is resolved when nesting is compiled
away. The second selector serializer (326 lines) is deleted (#43024)

**Colors and `Bun.color`**

- Keep light-dark() as written under the default browser targets
(#42009). 227 real stylesheets
- Number and percentage channels parse in `lab()`, `lch()`, `oklab()`,
`oklch()`, `hsl()` and `hwb()`; the hue of `lch` / `oklch` is no longer
scaled by alpha (#45084). 130 real stylesheets
- `Bun.color` returns `null` for `currentColor`, system colors and
`light-dark()` in the converting formats, instead of the CSS keyword as
a string (#33047). 130 real stylesheets
- Relative colors: channel keywords are on the scale of their function
(`rgb(from #369 calc(r + 10) g b)`), the origin's alpha is kept, a
relative color kept as written gets no `@supports` copies, and a result
outside sRGB is not folded (#45362). 31 real stylesheets
- Keep rotate: 0deg out of none, and keep the origin alpha in relative
colors (#38486). 13 real stylesheets
- Compute the D50 reference white in f32 so greys convert to lab(l 0 0)
like lightningcss (#38535). 7 real stylesheets
- Return a missing hue for a grey from conversions into lch, oklch, hsl
and hwb (#45201). 5 real stylesheets
- Fix color(a98-rgb), srgb-linear relative colors and color-mix(in
xyz-d50) (#38487). 4 real stylesheets
- Match named colors without regard to ASCII case (#45092). 3 real
stylesheets
- Fold min(), max() and clamp() over plain numbers (#39515)
- `calc(l - .08)` in a percentage channel of a relative color became NaN
and printed `#000` (also #45970) (#45971)
- Bun.color: convert color() inputs instead of returning them as a CSS
string (#38488)
- A `color-mix(in srgb, ...)` result outside sRGB prints `color(srgb
...)` instead of a gamut-mapped hex (#39269)

**`calc()` and numbers**

- Rounding to 6 significant digits ate into the integer part:
`z-index:2147483647` became `2147480000`, `max-width:9999999px` became
`10000000px`, `--x:1234567.5` became `1234570`. It now only cuts a
fraction short: a value kept as tokens prints its source text, a typed
value prints every integer digit and at least one digit of the fraction
(#46715). 43 real stylesheets
- A number without a unit is no longer repaired into `px`
(`height:1.5715` is dropped by a standards-mode browser, so it prints as
written), `calc(-1 * 0)` is not a length, and on a property of the range
[0,∞] (sizes, border radii, padding, border and outline widths, gap,
`flex-basis`, `font-size`) a `calc()` that folds below zero prints `0`
instead of an invalid negative literal, and a negative literal no longer
replaces the valid declaration before it (`width:10px;width:-5px`
printed `width:-5px`) (#42104). 24 real stylesheets
- Print numbers from the f32's shortest digits, not its f64 expansion
(#38565). 17 real stylesheets
- Print negative numbers below 1 with a single minus sign (#38516)
- Keep math functions over two different length units as written
(#38489)
- Fold rem() with a truncated quotient, so it keeps the sign of the
dividend (#32286)
- A one-argument `min()` / `max()` around a sum printed without any
function (`width:100% - 20px`) (#46145)
- `round()` breaks ties upward and handles a negative step as browsers
do (#45249)

**CSS modules**

- A CSS module exports its `@keyframes` names (`styles.spin`). Hashing
is unchanged (#46720). 145 real stylesheets
- A name inside `:global(...)` prints plain but was exported as
`name_<hash>`, a class no rule has (also #38573) (#45487). 90 real
stylesheets
- Keep container names global in CSS modules (#45247). 6 real
stylesheets
- Tag the keyframes name when an `animation` value falls back to raw
tokens (#46034). 6 real stylesheets
- In a plain stylesheet `composes: a from "./b.css"` was treated as an
import: the file was inlined and an absolute path printed (#45961). 4
real stylesheets
- Drop a rejected composes instead of failing the file at print time
(#38538). 1 real stylesheets
- Keep every rule's custom properties for the composes conflict check
(#38523)
- Keep composes working after a nested rule in the same block (#38520)
- `IdentOrRef::hash` read 2 of 16 bytes, so in release builds every
class of a module hashed alike (#30784)

**Tokens, `url()`, diagnostics**

- The block skip stack no longer allocates on every block exit (#39533)
- Report the diagnostic's line without its line breaks in
position.lineText (#38342). 192 real stylesheets
- Compare `url()` values by their text (#46895). 26 real stylesheets
- Values kept as tokens keep the blanks they were written with instead
of having them re-invented at print time: `attr(x type(<number>))`
printed `type(< number> )`, which browsers drop. Minified output still
drops the blanks that cannot matter (#45577). 19 real stylesheets
- The IE star hack (`*zoom:1`) failed the whole build. The declaration
is skipped, as every browser does. It is the one syntax error that
builds: tachyons, handsontable and jasmine ship it (#46751). 8 real
stylesheets
- 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).
7 real stylesheets
- Move the skip loop of parse_until_before out of the generic body
(#39520)
- Remove dead code from bun_css: the unused `impl Default for Property`
(#45618)
- Default the at-rule and qualified-rule parser traits, drop the
url_to_css shim (#31999)

**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**

- `css-fuzz.test.ts` never ran in CI and timed out on debug builds; it
is a deterministic table now (#38500)
- `nth-anplusb-ident.test.ts` counts heaps in a child process (#46331)
- `color.test.ts` checks the ansi-16 sweep with one `expect()` (#42824)
- Not CSS, but it failed this PR's CI and fails on `main` too:
`ws-proxy.test.ts` on Windows, "a message sent during a TLS
renegotiation arrives after it". The helper server now renegotiates from
the callback of the frame's write (#46170)

**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`; #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 (#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

- Duplicates of a pull request that lands here: #36961 (of #37979),
#38478 (of #46662), #38480 (of #46733), #38482 (of #46715), #38491 (of
#38500), #38508 (of #45201), #38513 (of #45362), #38573 (of #45487),
#38640 (of #38500), #45525 (of #45247), #45589 (of #46756), #45970 (of
#45971)
- Already fixed on `main`: #38674
- Right, but for input nobody writes (0 of 10,116 stylesheets, and not
new syntax): #31916, #38526, #38592, #39349, #41139, #41801, #42042,
#42484, #43078, #45604, #45908, #46086, #46221, #46224, #46273, #46694,
#46707, #46737, #46755, #46816, #46870, #46934
- Right or harmless, but not worth their code, their bigger output or
the behavior they change: #30905, #31924, #33332, #34426, #36225,
#38006, #38512, #38547, #38551, #38561, #38572, #38576, #38607, #38623,
#38667, #38692, #42106, #42912, #42918, #43407, #43434, #44700, #45442,
#45546, #45747, #45759, #45825, #45946, #46061, #46127, #46206
- Need a decision (see above), stay open: #40368, #40369, #44647,
#44648, #45814
- Stays open: #45787

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.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Part of this landed in #47025 (7942008). Its description says what was taken.

A width / height / min-* / max-* with var() or env() was printed before the plain fallback written above it, so the fallback won. 26 real stylesheets

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

I checked main at 7942008 against the tests of this PR and against real CSS.

  • On main: the size part. A width, height, min-* or max-* value with var(), env(), inherit, calc-size() or anchor-size() now stays after the fallback above it.
  • Not on main: color, text-shadow and the four logical border-*-radius longhands. A value that does not parse still replaces the parsed declaration before it. a{color:red;color:banana} prints a{color:banana}.
  • Chrome 153 computes the same style from source and output for 678 of the 688 rows of this PR's test block. On 1.4.2 it is 645. The 10 rows that remain are the three properties above.
  • I found no real stylesheet where that remainder shows. 6,778 stylesheets from 324 npm packages have 368 distinct pairs of color or text-shadow declarations in one block. In 353 the later value has var() or env(), which every default target reads. In none of the other 15 does a value that Bun cannot parse follow a valid one.
  • For all properties, the same stylesheets have 2,925 distinct cases of one property declared more than once in a block. On 1.4.2, Chrome computes a different style from the output for 58 of them (57 are sizes with var() or env()). On main, for none.

Nothing more from my side on this one.

@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my comment above. The color part does show in real CSS outside that npm corpus:

  • TRezendes/pride_palette pride-palette.css writes color: #000000; color: contrast-color(#ff69b4) in 232 rules. Main prints none of the 232 fallbacks.
  • GoogleChrome/lighthouse viewer.css writes color: #0000ee; color: -webkit-link. Main prints only color:-webkit-link.
  • @csstools/postcss-contrast-color-function prints color: rgb(0, 0, 0); color: contrast-color(...). Main prints that pair without the first declaration.

#47284 carries the color and text-shadow part with a narrower rule than this PR had. A value with var() or env() still replaces the earlier declaration, so the 353 pairs counted above do not change.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants