From 2525a6378dfbd977f4931571588d3366f9b1f155 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:23:55 +0000 Subject: [PATCH 1/6] css: keep an unparsed width/height/color after the declarations written 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. --- src/css/properties/prefix_handler.rs | 10 ++++----- src/css/properties/size.rs | 2 ++ test/js/bun/css/css.test.ts | 33 +++++++++++++++++++++++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/css/properties/prefix_handler.rs b/src/css/properties/prefix_handler.rs index 9e294d3f3c54..1ba9bde9843c 100644 --- a/src/css/properties/prefix_handler.rs +++ b/src/css/properties/prefix_handler.rs @@ -129,13 +129,11 @@ impl FallbackHandler { return false; }; + // Browsers drop a value they cannot parse, so it cannot stand in for the + // declaration before it. A later parsed value still replaces it. context.add_unparsed_fallbacks(arena, &mut unparsed); - if let Some(i) = *index { - dest[i] = Property::Unparsed(unparsed); - } else { - *index = Some(dest.len()); - dest.push(Property::Unparsed(unparsed)); - } + *index = Some(dest.len()); + dest.push(Property::Unparsed(unparsed)); return true; } diff --git a/src/css/properties/size.rs b/src/css/properties/size.rs index da77b28b6cdf..ec234950507d 100644 --- a/src/css/properties/size.rs +++ b/src/css/properties/size.rs @@ -469,6 +469,7 @@ macro_rules! property_helper { macro_rules! logical_unparsed_helper { ($this:expr, $unparsed:expr, $physical_id:expr, $physical_flag:expr, $logical_supported:expr, $dest:expr, $context:expr) => {{ + $this.flush($dest, $context); let bump = $dest.bump(); if $logical_supported { $this.flushed_properties.insert( @@ -697,6 +698,7 @@ impl SizeHandler { | PropertyIdTag::MaxWidth | PropertyIdTag::MinHeight | PropertyIdTag::MaxHeight => { + self.flush(dest, context); self.flushed_properties.insert( SizeProperty::try_from_property_id_tag(unparsed.property_id.tag()).unwrap(), ); diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 5ecfac95ab76..598e1947fc73 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -171,7 +171,7 @@ describe("css tests", () => { width: calc(infinity * -1px); height: infinity; }`, - indoc`.rounded-full{height:infinity;border-radius:3.40282e38px;width:-3.40282e38px}`, + indoc`.rounded-full{width:-3.40282e38px;height:infinity;border-radius:3.40282e38px}`, ); // NaN is valid inside calc() (CSS Values 4 "Infinities, NaN, and Signed Zero"). @@ -232,6 +232,37 @@ describe("css tests", () => { ); // ideally -400% - 8vh + 3ic minify_test(`a { top: calc(100% - 1 * 2 - 8 * 2); }`, `a{top:calc(100% - 2 - 16)}`); // ideally 100% - 18 }); + describe("a value that does not parse keeps the declaration before it", () => { + // Browsers drop a declaration they cannot parse, so the one before it applies. The + // minifier must neither let such a value replace the earlier one nor move it ahead of it. + minify_test(`a { color: red; color: banana }`, `a{color:red;color:banana}`); + minify_test(`a { color: red; color: rgb(1 2) }`, `a{color:red;color:rgb(1 2)}`); + minify_test(`a { color: red; color: var(--c) }`, `a{color:red;color:var(--c)}`); + minify_test(`a { color: banana; color: red }`, `a{color:red}`); + minify_test(`a { color: red; color: banana; color: blue }`, `a{color:red;color:#00f}`); + minify_test( + `a { text-shadow: 0 0 1px red; text-shadow: 0 0 banana }`, + `a{text-shadow:0 0 1px red;text-shadow:0 0 banana}`, + ); + // lightningcss#547: these were emitted after the unparsed value, in the wrong order. + minify_test(`a { width: 100px; width: var(--w) }`, `a{width:100px;width:var(--w)}`); + minify_test(`a { height: 100vh; height: 100dvb; height: var(--h) }`, `a{height:100dvb;height:var(--h)}`); + minify_test(`a { width: 100px; width: anchor-size(width) }`, `a{width:100px;width:anchor-size(width)}`); + minify_test(`a { max-height: 1px; max-height: banana }`, `a{max-height:1px;max-height:banana}`); + minify_test(`a { inline-size: 1px; inline-size: var(--w) }`, `a{inline-size:1px;inline-size:var(--w)}`); + minify_test(`a { min-width: 1px; height: 2px; width: env(foo) }`, `a{min-width:1px;height:2px;width:env(foo)}`); + minify_test(`a { width: var(--w); width: 100px }`, `a{width:var(--w);width:100px}`); + prefix_test( + `a { inline-size: 1px; inline-size: var(--w) }`, + indoc` + a { + width: 1px; + width: var(--w); + } + `, + { safari: 8 << 16 }, + ); + }); describe("border_spacing", () => { minify_test( ` From 6ce4700297b1b8cf80723b9538547bac5312c7fd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:57:17 +0000 Subject: [PATCH 2/6] css: keep a logical border radius before a later unparsed value for the 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. --- src/css/properties/border_radius.rs | 9 +++++++-- test/js/bun/css/css.test.ts | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/css/properties/border_radius.rs b/src/css/properties/border_radius.rs index b8df92a8e0f1..8b42870b5df2 100644 --- a/src/css/properties/border_radius.rs +++ b/src/css/properties/border_radius.rs @@ -210,13 +210,18 @@ macro_rules! property_helper { macro_rules! logical_property_helper { ($self:expr, $d:expr, $ctx:expr, $prop:ident, $val:expr) => {{ - if $self.category != PropertyCategory::Logical { + let val = $val; + // An unparsed value may be one browsers drop, so the value before it is kept + // as its fallback. + if $self.category != PropertyCategory::Logical + || ($self.$prop.is_some() && matches!(val, Property::Unparsed(_))) + { $self.flush($d, $ctx); } // `Property` itself // has no blanket `Clone`; callers pass an already-deep_clone'd `Property`. - $self.$prop = Some($val); + $self.$prop = Some(val); $self.category = PropertyCategory::Logical; $self.has_any = true; }}; diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 598e1947fc73..48623121792e 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -262,6 +262,23 @@ describe("css tests", () => { `, { safari: 8 << 16 }, ); + // The logical corner radii overwrote the buffered value instead. + minify_test( + `a { border-start-start-radius: 1px; border-start-start-radius: var(--r) }`, + `a{border-start-start-radius:1px;border-start-start-radius:var(--r)}`, + ); + minify_test( + `a { border-end-end-radius: 1px; border-end-end-radius: banana }`, + `a{border-end-end-radius:1px;border-end-end-radius:banana}`, + ); + minify_test( + `a { border-start-end-radius: var(--r); border-start-end-radius: 1px }`, + `a{border-start-end-radius:1px}`, + ); + minify_test( + `a { border-top-left-radius: 1px; border-top-left-radius: var(--r) }`, + `a{border-top-left-radius:1px;border-top-left-radius:var(--r)}`, + ); }); describe("border_spacing", () => { minify_test( From 4396ff575dd01e0e5b9e22a1a85b1e1171b78061 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:58:41 +0000 Subject: [PATCH 3/6] css: shorten two comments --- src/css/properties/border_radius.rs | 3 +-- src/css/properties/prefix_handler.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/css/properties/border_radius.rs b/src/css/properties/border_radius.rs index 8b42870b5df2..0f528c0b2391 100644 --- a/src/css/properties/border_radius.rs +++ b/src/css/properties/border_radius.rs @@ -211,8 +211,7 @@ macro_rules! property_helper { macro_rules! logical_property_helper { ($self:expr, $d:expr, $ctx:expr, $prop:ident, $val:expr) => {{ let val = $val; - // An unparsed value may be one browsers drop, so the value before it is kept - // as its fallback. + // An unparsed value keeps the one before it as its fallback. if $self.category != PropertyCategory::Logical || ($self.$prop.is_some() && matches!(val, Property::Unparsed(_))) { diff --git a/src/css/properties/prefix_handler.rs b/src/css/properties/prefix_handler.rs index 1ba9bde9843c..4ba5e6adfba4 100644 --- a/src/css/properties/prefix_handler.rs +++ b/src/css/properties/prefix_handler.rs @@ -129,8 +129,7 @@ impl FallbackHandler { return false; }; - // Browsers drop a value they cannot parse, so it cannot stand in for the - // declaration before it. A later parsed value still replaces it. + // Appended, not replaced: the declaration before it is its fallback. context.add_unparsed_fallbacks(arena, &mut unparsed); *index = Some(dest.len()); dest.push(Property::Unparsed(unparsed)); From c2689462d35538352d8dc224ff702d782d8fb70d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:19:47 +0000 Subject: [PATCH 4/6] test(css): cover inherit, calc-size() and contrast-color() after a parsed declaration --- test/js/bun/css/css.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 68dccab08823..5cffad25715c 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -237,6 +237,7 @@ describe("css tests", () => { // minifier must neither let such a value replace the earlier one nor move it ahead of it. minify_test(`a { color: red; color: banana }`, `a{color:red;color:banana}`); minify_test(`a { color: red; color: rgb(1 2) }`, `a{color:red;color:rgb(1 2)}`); + minify_test(`a { color: red; color: contrast-color(blue) }`, `a{color:red;color:contrast-color(blue)}`); minify_test(`a { color: red; color: var(--c) }`, `a{color:red;color:var(--c)}`); minify_test(`a { color: banana; color: red }`, `a{color:red}`); minify_test(`a { color: red; color: banana; color: blue }`, `a{color:red;color:#00f}`); @@ -248,6 +249,11 @@ describe("css tests", () => { minify_test(`a { width: 100px; width: var(--w) }`, `a{width:100px;width:var(--w)}`); minify_test(`a { height: 100vh; height: 100dvb; height: var(--h) }`, `a{height:100dvb;height:var(--h)}`); minify_test(`a { width: 100px; width: anchor-size(width) }`, `a{width:100px;width:anchor-size(width)}`); + minify_test(`a { width: 100px; width: calc-size(auto, size) }`, `a{width:100px;width:calc-size(auto,size)}`); + // `inherit` is valid in every browser, so the reorder made the earlier value win. + minify_test(`a { width: 100px; width: inherit }`, `a{width:100px;width:inherit}`); + minify_test(`a { min-width: 100px; min-width: var(--w) }`, `a{min-width:100px;min-width:var(--w)}`); + minify_test(`a { max-height: 100px; max-height: inherit }`, `a{max-height:100px;max-height:inherit}`); minify_test(`a { max-height: 1px; max-height: banana }`, `a{max-height:1px;max-height:banana}`); minify_test(`a { inline-size: 1px; inline-size: var(--w) }`, `a{inline-size:1px;inline-size:var(--w)}`); minify_test(`a { min-width: 1px; height: 2px; width: env(foo) }`, `a{min-width:1px;height:2px;width:env(foo)}`); From 898c856f0a787164551cde7ef454fb132851cbdf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:57:05 +0000 Subject: [PATCH 5/6] test(css): check every typed property keeps a parsed value ahead of an unparsed one --- test/js/bun/css/css.test.ts | 100 ++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 5cffad25715c..8b8492205cb2 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -285,6 +285,106 @@ describe("css tests", () => { `a { border-top-left-radius: 1px; border-top-left-radius: var(--r) }`, `a{border-top-left-radius:1px;border-top-left-radius:var(--r)}`, ); + // The same for every typed property: `p: ; p: ` prints as written. + test("every typed property keeps a parsed value ahead of an unparsed one", () => { + const parsed: [value: string, properties: string][] = [ + ["normal", "align-content align-items place-content place-items"], + ["auto", "align-self place-self"], + ["1s a", "animation"], + ["a", "animation-name"], + ["1/2", "aspect-ratio"], + ["hidden", "backface-visibility overflow overflow-x overflow-y visibility"], + [ + "red", + `background background-color border-block-color border-block-end-color border-block-start-color + border-bottom-color border-color border-inline-color border-inline-end-color border-inline-start-color + border-left-color border-right-color border-top-color color outline-color text-decoration-color + text-emphasis-color`, + ], + ["fixed", "background-attachment"], + ["padding-box", "background-clip background-origin mask-clip mask-origin"], + ["linear-gradient(red,red)", "background-image border-image-source mask mask-border-source mask-image"], + ["1px 2px", "background-position mask-position perspective-origin transform-origin"], + [ + "1px", + `background-position-x background-position-y background-size block-size border-block-end-width + border-block-start-width border-block-width border-bottom-left-radius border-bottom-right-radius + border-bottom-width border-end-end-radius border-end-start-radius border-image-outset border-image-width + border-inline-end-width border-inline-start-width border-inline-width border-left-width border-radius + border-right-width border-spacing border-start-end-radius border-start-start-radius border-top-left-radius + border-top-right-radius border-top-width border-width bottom column-gap flex-basis gap height inline-size + inset inset-block inset-block-end inset-block-start inset-inline inset-inline-end inset-inline-start left + margin margin-block margin-block-end margin-block-start margin-bottom margin-inline margin-inline-end + margin-inline-start margin-left margin-right margin-top mask-border-outset mask-border-width + mask-position-x mask-position-y mask-size max-block-size max-height max-inline-size max-width + min-block-size min-height min-inline-size min-width outline-width padding padding-block padding-block-end + padding-block-start padding-bottom padding-inline padding-inline-end padding-inline-start padding-left + padding-right padding-top perspective right row-gap scroll-margin scroll-margin-block + scroll-margin-block-end scroll-margin-block-start scroll-margin-bottom scroll-margin-inline + scroll-margin-inline-end scroll-margin-inline-start scroll-margin-left scroll-margin-right + scroll-margin-top scroll-padding scroll-padding-block scroll-padding-block-end scroll-padding-block-start + scroll-padding-bottom scroll-padding-inline scroll-padding-inline-end scroll-padding-inline-start + scroll-padding-left scroll-padding-right scroll-padding-top top translate width`, + ], + ["no-repeat", "background-repeat mask-repeat"], + [ + "1px solid red", + `border border-block border-block-end border-block-start border-bottom border-inline border-inline-end + border-inline-start border-left border-right border-top outline`, + ], + [ + "solid", + `border-block-end-style border-block-start-style border-block-style border-bottom-style + border-inline-end-style border-inline-start-style border-inline-style border-left-style border-right-style + border-style border-top-style outline-style`, + ], + ["linear-gradient(red,red) 1", "border-image mask-border"], + ["round", "border-image-repeat mask-border-repeat"], + ["1", "border-image-slice flex flex-grow flex-shrink mask-border-slice order"], + ["0 0 1px red", "box-shadow text-shadow"], + ["border-box", "box-sizing"], + ["dark", "color-scheme"], + ["rtl", "direction"], + ["flex", "display"], + ["column", "flex-direction"], + ["column wrap", "flex-flow"], + ["wrap", "flex-wrap"], + ["12px serif", "font"], + ["serif", "font-family"], + ["12px", "font-size"], + ["50%", "font-stretch"], + ["italic", "font-style"], + ["small-caps", "font-variant-caps"], + ["700", "font-weight"], + ["center", "justify-content justify-items justify-self"], + ["2", "line-height scale"], + ["alpha", "mask-border-mode mask-mode mask-type"], + ["add", "mask-composite"], + [".5", "opacity"], + ["absolute", "position"], + ["1deg", "rotate"], + ["ellipsis", "text-overflow"], + ["scale(2)", "transform"], + ["fill-box", "transform-box"], + ["preserve-3d", "transform-style"], + ["all 1s", "transition"], + ["1s", "transition-delay transition-duration"], + ["opacity", "transition-property"], + ["linear", "transition-timing-function"], + ]; + // These parse `inherit` as a custom identifier, so it is not an unparsed value for them. + const inheritIsIdent = ["color-scheme", "font-family", "transition", "transition-property"]; + const sources = parsed.flatMap(([value, properties]) => + properties + .split(/\s+/) + .flatMap(property => + ["var(--x)", "banana(1)", "inherit"] + .filter(unparsed => unparsed !== "inherit" || !inheritIsIdent.includes(property)) + .map(unparsed => `a{${property}:${value};${property}:${unparsed}}`), + ), + ); + expect(sources.map(css => minify_test_with_options(css, css))).toEqual(sources); + }); }); describe("border_spacing", () => { minify_test( From a1e16bd132fd3c517a0626972b75e81ea6e25ccc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:53:53 +0000 Subject: [PATCH 6/6] test(css): say why four properties skip the inherit pair --- test/js/bun/css/css.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 8b8492205cb2..332ea0eb1215 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -372,14 +372,14 @@ describe("css tests", () => { ["opacity", "transition-property"], ["linear", "transition-timing-function"], ]; - // These parse `inherit` as a custom identifier, so it is not an unparsed value for them. - const inheritIsIdent = ["color-scheme", "font-family", "transition", "transition-property"]; + // Their typed parsers accept `inherit` as an identifier, so it is not an unparsed value for them. + const acceptInherit = ["color-scheme", "font-family", "transition", "transition-property"]; const sources = parsed.flatMap(([value, properties]) => properties .split(/\s+/) .flatMap(property => ["var(--x)", "banana(1)", "inherit"] - .filter(unparsed => unparsed !== "inherit" || !inheritIsIdent.includes(property)) + .filter(unparsed => unparsed !== "inherit" || !acceptInherit.includes(property)) .map(unparsed => `a{${property}:${value};${property}:${unparsed}}`), ), );