From b0287040e282df885a99ed0eeee54d0e06b3c64c Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 8 Sep 2026 14:55:20 +0800 Subject: [PATCH 1/3] fix: factor shared custom properties in adjacent rules --- src/lib.rs | 406 +++++++++++++++++++++++++++++++++++++++++++++++ src/rules/mod.rs | 160 ++++++++++++++++++- src/selector.rs | 15 ++ 3 files changed, 577 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 6416b7bae..c0d962e52 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9847,6 +9847,412 @@ mod tests { ); } + #[test] + fn test_merge_common_custom_properties() { + test( + r#" + html { + --foreground: #000; + --foreground-rgb: 0,0,0; + --background: #fff; + } + + html[data-theme="dark"] { + --foreground: #000; + --foreground-rgb: 0,0,0; + --background: #222; + } + "#, + indoc! {r#" + html { + --background: #fff; + } + + html, html[data-theme="dark"] { + --foreground: #000; + --foreground-rgb: 0,0,0; + } + + html[data-theme="dark"] { + --background: #222; + } + "#}, + ); + + // Either original rule may become empty after extraction. + test( + r#" + .a { + --shared: long-value; + } + + .b { + --shared: long-value; + color: red; + } + "#, + indoc! {r#" + .a, .b { + --shared: long-value; + } + + .b { + color: red; + } + "#}, + ); + + test( + r#" + .a { + --shared: long-value; + color: red; + } + + .b { + --shared: long-value; + } + "#, + indoc! {r#" + .a { + color: red; + } + + .a, .b { + --shared: long-value; + } + "#}, + ); + + // Shared declarations retain their importance. + test( + r#" + .a { + --shared: long-value !important; + color: red; + } + + .b { + --shared: long-value !important; + color: blue; + } + "#, + indoc! {r#" + .a { + color: red; + } + + .a, .b { + --shared: long-value !important; + } + + .b { + color: #00f; + } + "#}, + ); + + // Variable references do not depend on declaration order. + test( + r#" + .a { + --shared: var(--color); + --color: red; + } + + .b { + --color: blue; + --shared: var(--color); + } + "#, + indoc! {r#" + .a { + --color: red; + } + + .a, .b { + --shared: var(--color); + } + + .b { + --color: blue; + } + "#}, + ); + + // The all shorthand does not reset custom properties. + test( + r#" + .a { + all: initial; + --shared: long-value; + } + + .b { + all: unset; + --shared: long-value; + } + "#, + indoc! {r#" + .a { + all: initial; + } + + .a, .b { + --shared: long-value; + } + + .b { + all: unset; + } + "#}, + ); + + // Rules can share declarations within the same conditional block. + test( + r#" + @media print { + .a { + --shared: long-value; + color: red; + } + + .b { + --shared: long-value; + color: blue; + } + } + "#, + indoc! {r#" + @media print { + .a { + color: red; + } + + .a, .b { + --shared: long-value; + } + + .b { + color: #00f; + } + } + "#}, + ); + + // Factor when the serialized declaration saves one byte over the extra selectors. + test( + r#" + .a { + --x: ab; + color: red; + } + + .b { + --x: ab; + color: green; + } + "#, + indoc! {r#" + .a { + color: red; + } + + .a, .b { + --x: ab; + } + + .b { + color: green; + } + "#}, + ); + } + + #[test] + fn test_merge_common_custom_properties_boundaries() { + for source in [ + // Names are case-sensitive. + indoc! {r#" + .a { + --shared: long-value; + color: red; + } + + .b { + --SHARED: long-value; + color: green; + } + "#}, + // Values must match. + indoc! {r#" + .a { + --shared: long-value; + color: red; + } + + .b { + --shared: other-value; + color: green; + } + "#}, + // Importance must match. + indoc! {r#" + .a { + color: red; + --shared: long-value !important; + } + + .b { + --shared: long-value; + color: green; + } + "#}, + // Repeated selectors would cost more than the shared declaration. + indoc! {r#" + .long-selector-a { + --x: 0; + color: red; + } + + .long-selector-b { + --x: 0; + color: green; + } + "#}, + // A tie is left unchanged. + indoc! {r#" + .a { + --x: 0; + color: red; + } + + .b { + --x: 0; + color: green; + } + "#}, + // Do not move declarations across intervening rules. + indoc! {r#" + .a { + --shared: long-value; + color: red; + } + + .c { + --shared: override; + } + + .b { + --shared: long-value; + color: green; + } + "#}, + // Do not move declarations across conditional boundaries. + indoc! {r#" + .a { + --shared: long-value; + color: red; + } + + @media print { + .b { + --shared: long-value; + color: green; + } + } + "#}, + // Ordinary declarations may have shorthand interactions. + indoc! {r#" + .a { + color: red; + padding-top: 20px; + } + + .b { + color: green; + padding-top: 20px; + } + "#}, + ] { + test(source, source); + } + + // Unsupported selectors must not invalidate the other rule in a selector list. + let source = indoc! {r#" + .a { + --shared: long-value; + color: red; + } + + .b:has(.c) { + --shared: long-value; + color: green; + } + "#}; + prefix_test( + source, + source, + Browsers { + chrome: Some(90 << 16), + ..Browsers::default() + }, + ); + + // CSS Modules can change selector and variable lengths when printing. + let source = indoc! {r#" + html { + --shared: long-value; + color: red; + } + + body { + --shared: long-value; + color: green; + } + "#}; + test_with_options( + source, + source, + ParserOptions { + css_modules: Some(Default::default()), + ..ParserOptions::default() + }, + ); + + // Nested selectors can expand to much longer selector lists when lowered. + for source in [ + indoc! {r#" + .parent { + &.a { + --shared: long-value; + color: red; + } + + &.b { + --shared: long-value; + color: green; + } + } + "#}, + indoc! {r#" + .parent { + :is(&, .a) { + --shared: long-value; + color: red; + } + + :is(&, .b) { + --shared: long-value; + color: green; + } + } + "#}, + ] { + test(source, source); + } + } + #[test] fn test_merge_rules() { test( diff --git a/src/rules/mod.rs b/src/rules/mod.rs index e491ba81c..51445fb3b 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -70,11 +70,13 @@ use crate::dependencies::{Dependency, ImportDependency}; use crate::error::{MinifyError, ParserError, PrinterError, PrinterErrorKind}; use crate::parser::{parse_rule_list, parse_style_block, DefaultAtRule, DefaultAtRuleParser, TopLevelRuleParser}; use crate::prefixes::Feature; -use crate::printer::Printer; +use crate::printer::{Printer, PrinterOptions}; +use crate::properties::custom::CustomPropertyName; +use crate::properties::Property; use crate::rules::keyframes::KeyframesName; -use crate::selector::{is_compatible, is_equivalent, Component, Selector, SelectorList}; +use crate::selector::{has_nesting, is_compatible, is_equivalent, Component, Selector, SelectorList}; use crate::stylesheet::ParserOptions; -use crate::targets::{should_compile, TargetsWithSupportsScope}; +use crate::targets::{should_compile, Targets, TargetsWithSupportsScope}; use crate::traits::{AtRuleParser, ToCss}; use crate::values::string::CowArcStr; use crate::vendor_prefix::VendorPrefix; @@ -968,11 +970,161 @@ impl<'i, T: Clone> CssRuleList<'i, T> { } } - self.0 = rules; + // The duplicate-rule keys borrow rules and their declarations. Finish using + // them before extracting declarations into new rules. + drop(style_rules); + self.0 = if context.css_modules { + rules + } else { + factor_custom_properties(rules, context.targets.current) + }; Ok(()) } } +fn factor_custom_properties<'i, T>(rules: Vec>, targets: Targets) -> Vec> { + if rules.len() < 2 { + return rules; + } + + let mut result = Vec::with_capacity(rules.len()); + for mut rule in rules { + if matches!(rule, CssRule::Ignored) { + continue; + } + + if let (Some(CssRule::Style(previous)), CssRule::Style(current)) = (result.last_mut(), &mut rule) { + if let Some(shared) = extract_common_custom_properties(previous, current, targets) { + if previous.is_empty() { + result.pop(); + } + result.push(CssRule::Style(shared)); + } + if current.is_empty() { + continue; + } + } + result.push(rule); + } + result +} + +fn extract_common_custom_properties<'i, T>( + previous: &mut StyleRule<'i, T>, + current: &mut StyleRule<'i, T>, + targets: Targets, +) -> Option> { + if !previous.rules.0.is_empty() + || !current.rules.0.is_empty() + || previous.loc.source_index != current.loc.source_index + || previous.vendor_prefix != current.vendor_prefix + || previous.vendor_prefix.intersects(!VendorPrefix::None) + { + return None; + } + + // Custom properties have no shorthand interactions, and `all` does not reset + // them. Only equal names, values, and importance can move into the shared rule. + let mut properties = HashMap::new(); + for (property, important) in previous.declarations.iter() { + if let Property::Custom(custom) = property { + if matches!(custom.name, CustomPropertyName::Custom(_)) { + properties.insert((&custom.name, important), property); + } + } + } + if properties.is_empty() { + return None; + } + + let mut common = HashSet::new(); + let mut css = String::new(); + let mut printer = Printer::new( + &mut css, + PrinterOptions { + minify: true, + targets, + ..PrinterOptions::default() + }, + ); + for (property, important) in current.declarations.iter() { + if let Property::Custom(custom) = property { + if properties + .get(&(&custom.name, important)) + .is_some_and(|other| **other == *property) + { + property.to_css(&mut printer, important).ok()?; + printer.write_char(';').ok()?; + common.insert((custom.name.clone(), important)); + } + } + } + if common.is_empty() + || previous.selectors.0.iter().any(has_nesting) + || current.selectors.0.iter().any(has_nesting) + || !previous.is_compatible(targets) + || !current.is_compatible(targets) + { + return None; + } + + let shared_size = css.len() - 1; + let options = || PrinterOptions { + minify: true, + targets, + ..PrinterOptions::default() + }; + let previous_selectors_size = previous.selectors.to_css_string(options()).ok()?.len(); + let current_selectors_size = current.selectors.to_css_string(options()).ok()?.len(); + let previous_remains = previous.declarations.len() > common.len(); + let current_remains = current.declarations.len() > common.len(); + + // Account for the extra selector list and braces, and for semicolons removed + // from the original blocks. Do not factor when the minified output would grow. + let overhead = match (previous_remains, current_remains) { + (true, true) => previous_selectors_size + current_selectors_size + 1, + (false, true) => current_selectors_size, + (true, false) => previous_selectors_size, + (false, false) => 0, + }; + if shared_size <= overhead { + return None; + } + + let mut declarations = DeclarationBlock::new(); + for (previous, current, shared, important) in [ + ( + &mut previous.declarations.declarations, + &mut current.declarations.declarations, + &mut declarations.declarations, + false, + ), + ( + &mut previous.declarations.important_declarations, + &mut current.declarations.important_declarations, + &mut declarations.important_declarations, + true, + ), + ] { + let is_common = |property: &Property<'i>| match property { + Property::Custom(custom) => common.contains(&(custom.name.clone(), important)), + _ => false, + }; + shared.extend(previous.extract_if(.., |property| is_common(property))); + current.retain(|property| !is_common(property)); + } + + let mut selectors = previous.selectors.clone(); + selectors.0.extend(current.selectors.0.iter().cloned()); + Some(StyleRule { + selectors, + vendor_prefix: previous.vendor_prefix, + declarations, + rules: CssRuleList(vec![]), + loc: previous.loc, + }) +} + fn merge_style_rules<'i, T>( style: &mut StyleRule<'i, T>, last_style_rule: &mut StyleRule<'i, T>, diff --git a/src/selector.rs b/src/selector.rs index 548567a62..da4723f36 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -61,6 +61,21 @@ pub type SelectorList<'i> = parcel_selectors::SelectorList<'i, Selectors>; pub type Selector<'i> = parcel_selectors::parser::Selector<'i, Selectors>; /// An individual component within a selector. pub type Component<'i> = parcel_selectors::parser::Component<'i, Selectors>; + +/// Whether a selector contains nesting, including inside a functional pseudo class. +pub(crate) fn has_nesting(selector: &Selector) -> bool { + struct WithoutNesting; + impl<'i> parcel_selectors::visitor::SelectorVisitor<'i> for WithoutNesting { + type Impl = Selectors; + + fn visit_simple_selector(&mut self, component: &Component<'i>) -> bool { + !matches!(component, Component::Nesting) + } + } + + !selector.visit(&mut WithoutNesting) +} + /// A combinator. pub use parcel_selectors::parser::Combinator; From acc3980615b3e6f0a07d29331596c9630d06e617 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 8 Sep 2026 15:17:19 +0800 Subject: [PATCH 2/3] fix: cover cssnano rule merging boundaries --- src/lib.rs | 540 ++++++++++++++++++++++++++++++++++++++++++ src/rules/mod.rs | 98 +++++++- src/selector.rs | 30 ++- tests/cssnano/LICENSE | 22 ++ 4 files changed, 677 insertions(+), 13 deletions(-) create mode 100644 tests/cssnano/LICENSE diff --git a/src/lib.rs b/src/lib.rs index c0d962e52..f21807145 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10253,6 +10253,546 @@ mod tests { } } + #[test] + fn test_merge_common_custom_properties_revisit() { + // Adapted from cssnano's "should revisit a predecessor after replacing an adjacent pair". + let expected = indoc! {r#" + .a, .b { + --x: 1; + --color: red; + } + + .b { + --background: blue; + } + "#}; + test( + r#" + .a, .b { + --x: 1; + } + + .a { + --color: red; + } + + .b { + --color: red; + --background: blue; + } + "#, + expected, + ); + // Like cssnano's convergence test, a second pass must not find another merge. + test(expected, expected); + + // Extraction can also expose identical blocks of ordinary declarations. + let expected = indoc! {r#" + .a, .b { + color: red; + } + + .b, .c { + --shared: long-value; + } + + .c { + color: green; + } + "#}; + test( + r#" + .a { + color: red; + } + + .b { + --shared: long-value; + color: red; + } + + .c { + --shared: long-value; + color: green; + } + "#, + expected, + ); + test(expected, expected); + } + + #[test] + fn test_merge_common_custom_properties_selector_arguments() { + // Extend cssnano's unknown-selector and :host regression cases to nested arguments. + for source in [ + indoc! {r#" + .a { + --shared: very-long-shared-value; + color: red; + } + + :host(:nonsense) { + --shared: very-long-shared-value; + color: green; + } + "#}, + indoc! {r#" + .a { + --shared: very-long-shared-value; + color: red; + } + + ::slotted(:nonsense) { + --shared: very-long-shared-value; + color: green; + } + "#}, + indoc! {r#" + .a { + --shared: very-long-shared-value; + color: red; + } + + ::cue(:nonsense) { + --shared: very-long-shared-value; + color: green; + } + "#}, + indoc! {r#" + .a { + --shared: very-long-shared-value; + color: red; + } + + ::cue(.b .c) { + --shared: very-long-shared-value; + color: green; + } + "#}, + ] { + test(source, source); + } + } + + #[test] + fn test_merge_common_custom_properties_nested_has() { + // Nesting inside :has() must be detected just like nesting inside :is(). + let source = indoc! {r#" + .parent { + :has(&) { + --shared: very-long-shared-value; + color: red; + } + + :has(&.b) { + --shared: very-long-shared-value; + color: green; + } + } + "#}; + test(source, source); + } + + // Cases adapted from cssnano/postcss-merge-rules (MIT), with declarations + // changed to custom properties where appropriate: + // The upstream fixture license is preserved in tests/cssnano/LICENSE. + // https://github.com/cssnano/cssnano/blob/13b2373173ce49c78b8a2015d8c3f6b025ccfc3e/packages/postcss-merge-rules/test/index.js + #[test] + fn test_merge_common_custom_properties_cssnano() { + // Different declaration order (cssnano test/index.js:40). + test( + r#" + h1 { + --color: red; + --line-height: 1.5; + --font-size: 2em; + } + + h2 { + --font-size: 2em; + --color: red; + --line-height: 1.5; + } + "#, + indoc! {r#" + h1, h2 { + --color: red; + --line-height: 1.5; + --font-size: 2em; + } + "#}, + ); + + // Partial merging across three rules (cssnano test/index.js:194). + test( + r#" + h1 { + --color: red; + --text-decoration: underline; + } + + h2 { + --text-decoration: underline; + --color: green; + } + + h3 { + --font-weight: bold; + --color: green; + } + "#, + indoc! {r#" + h1 { + --color: red; + } + + h1, h2 { + --text-decoration: underline; + } + + h2, h3 { + --color: green; + } + + h3 { + --font-weight: bold; + } + "#}, + ); + + // The middle rule becomes empty (cssnano test/index.js:226). + test( + r#" + .test-1 { + --margin-top: 10px; + --margin-bottom: 20px; + } + + .test-2 { + --margin-top: 10px; + } + + .another-test { + --margin-top: 10px; + --margin-bottom: 30px; + } + "#, + indoc! {r#" + .test-1 { + --margin-bottom: 20px; + } + + .test-1, .test-2, .another-test { + --margin-top: 10px; + } + + .another-test { + --margin-bottom: 30px; + } + "#}, + ); + + // Repeated declarations are already deduplicated by Lightning CSS (cssnano test/index.js:478). + test( + r#" + h1 { + --display: block; + --display: block; + } + + h2 { + --display: block; + --display: block; + } + "#, + indoc! {r#" + h1, h2 { + --display: block; + } + "#}, + ); + + // An overridden value must not be extracted (cssnano test/index.js:510). + test( + r#" + .a { + --font-family: Arial; + --font-family: Helvetica; + } + + .b { + --font-family: Arial; + } + "#, + indoc! {r#" + .a { + --font-family: Helvetica; + } + + .b { + --font-family: Arial; + } + "#}, + ); + + // Only the final matching value survives (cssnano test/index.js:646). + test( + r#" + .a { + --margin-left: 2px; + color: red; + } + + .b { + --margin-left: 2px; + --margin-left: 1px; + --margin-left: 2px; + } + "#, + indoc! {r#" + .a { + color: red; + } + + .a, .b { + --margin-left: 2px; + } + "#}, + ); + + // Custom properties with related-looking names are independent (cssnano test/propertyRelations.js). + test( + r#" + .a { + --font: serif; + --font-size: 12px; + color: red; + } + + .b { + --font: serif; + --font-size: 18px; + color: green; + } + "#, + indoc! {r#" + .a { + --font-size: 12px; + color: red; + } + + .a, .b { + --font: serif; + } + + .b { + --font-size: 18px; + color: green; + } + "#}, + ); + + // A predecessor merge preserves normal and important overrides independently. + test( + r#" + .a, .b { + --color: black; + --fallback: stable; + --color: green !important; + } + + .a { + --color: red; + --shared: long-value; + } + + .b { + --color: red; + --shared: long-value; + --background: blue; + } + "#, + indoc! {r#" + .a, .b { + --color: red; + --fallback: stable; + --shared: long-value; + --color: green !important; + } + + .b { + --background: blue; + } + "#}, + ); + + // Logical and physical declarations retain their order (cssnano test/index.js:1261). + test( + r#" + .a { + --shared: long-value; + margin-inline-start: 1px; + margin-top: 2px; + } + + .b { + --shared: long-value; + margin-inline-start: 3px; + margin-top: 4px; + } + "#, + indoc! {r#" + .a { + margin-inline-start: 1px; + margin-top: 2px; + } + + .a, .b { + --shared: long-value; + } + + .b { + margin-inline-start: 3px; + margin-top: 4px; + } + "#}, + ); + + // A forgiving selector argument may contain unsupported selectors. + test( + r#" + .a { + --shared: very-very-long-shared-value; + color: red; + } + + :is(:nonsense, .b) { + --shared: very-very-long-shared-value; + color: green; + } + "#, + indoc! {r#" + .a { + color: red; + } + + .a, :is(:nonsense, .b) { + --shared: very-very-long-shared-value; + } + + :is(:nonsense, .b) { + color: green; + } + "#}, + ); + } + + #[test] + fn test_merge_common_custom_properties_cssnano_boundaries() { + for source in [ + // The selector cost exceeds the saving (cssnano test/index.js:303). + indoc! {r#" + .test0 { + --color: red; + --border: none; + --margin: 0; + } + + .longlonglonglong { + --color: green; + --border: none; + --margin: 0; + } + "#}, + // Different container queries (cssnano test/index.js:164). + indoc! {r#" + @container (width >= 200px) { + .mobile { + --display: none; + } + } + + @container (width <= 100px) { + .notMobile { + --display: none; + } + } + "#}, + // Different nested contexts (cssnano test/index.js:1004). + indoc! {r#" + @media (width >= 48rem) { + .wrapper { + --display: block; + } + } + + @supports (display: flex) { + @media (width >= 48rem) { + .wrapper { + --display: flex; + } + } + } + "#}, + // Keyframes use a separate rule type (cssnano test/index.js:662). + indoc! {r#" + @keyframes foo { + 0% { + --shared: long-value; + --opacity: 0; + } + + to { + --shared: long-value; + --opacity: 1; + } + } + "#}, + // Different cascade layers must not share rules. + indoc! {r#" + @layer base { + .a { + --shared: long-value; + color: red; + } + } + + @layer theme { + .b { + --shared: long-value; + color: green; + } + } + "#}, + // Mixed vendor pseudo selectors (cssnano test/index.js:325). + indoc! {r#" + code :-ms-placeholder-shown { + --shared: very-long-shared-value; + color: red; + } + + code::-moz-placeholder { + --shared: very-long-shared-value; + color: green; + } + "#}, + // Unknown and known selectors (cssnano test/index.js:878). + indoc! {r#" + p { + --shared: long-value; + color: red; + } + + :nonsense { + --shared: long-value; + color: green; + } + "#}, + ] { + test(source, source); + } + } + #[test] fn test_merge_rules() { test( diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 51445fb3b..12d925406 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -982,25 +982,35 @@ impl<'i, T: Clone> CssRuleList<'i, T> { } } -fn factor_custom_properties<'i, T>(rules: Vec>, targets: Targets) -> Vec> { +fn factor_custom_properties<'i, T>(mut rules: Vec>, targets: Targets) -> Vec> { if rules.len() < 2 { return rules; } let mut result = Vec::with_capacity(rules.len()); - for mut rule in rules { + rules.reverse(); + while let Some(mut rule) = rules.pop() { if matches!(rule, CssRule::Ignored) { continue; } if let (Some(CssRule::Style(previous)), CssRule::Style(current)) = (result.last_mut(), &mut rule) { + if merge_after_custom_property_extraction(previous, current, targets) { + rules.push(result.pop().unwrap()); + continue; + } if let Some(shared) = extract_common_custom_properties(previous, current, targets) { - if previous.is_empty() { - result.pop(); + let previous_is_empty = previous.is_empty(); + if !current.is_empty() { + rules.push(rule); } - result.push(CssRule::Style(shared)); - } - if current.is_empty() { + rules.push(CssRule::Style(shared)); + let previous = result.pop().unwrap(); + if !previous_is_empty { + rules.push(previous); + } + // A rewrite can enable a merge with the predecessor. Revisit only the + // changed neighborhood. Every rewrite strictly reduces serialized size. continue; } } @@ -1009,6 +1019,75 @@ fn factor_custom_properties<'i, T>(rules: Vec>, targets: Targets) result } +fn merge_after_custom_property_extraction<'i, T>( + previous: &mut StyleRule<'i, T>, + current: &mut StyleRule<'i, T>, + targets: Targets, +) -> bool { + let only_custom_properties = |declarations: &DeclarationBlock<'i>| { + declarations.iter().all(|(property, _)| { + matches!(property, Property::Custom(custom) if matches!(custom.name, CustomPropertyName::Custom(_))) + }) + }; + if (previous.selectors != current.selectors && previous.declarations != current.declarations) + || !previous.rules.0.is_empty() + || !current.rules.0.is_empty() + || previous.loc.source_index != current.loc.source_index + || (previous.vendor_prefix | current.vendor_prefix).intersects(!VendorPrefix::None) + || previous.selectors.0.iter().any(has_nesting) + || current.selectors.0.iter().any(has_nesting) + || !previous.is_compatible(targets) + || !current.is_compatible(targets) + { + return false; + } + + if previous.declarations == current.declarations { + if previous.selectors != current.selectors { + previous.selectors.0.extend(current.selectors.0.drain(..)); + } + previous.vendor_prefix |= current.vendor_prefix; + return true; + } + + if !(only_custom_properties(&previous.declarations) || only_custom_properties(¤t.declarations)) { + return false; + } + + // At least one block contains only custom properties, so ordinary properties + // cannot interact across the blocks. Resolve repeated custom properties within + // each importance level without re-running handlers that generate fallbacks. + for (previous, current) in [ + ( + &mut previous.declarations.declarations, + &mut current.declarations.declarations, + ), + ( + &mut previous.declarations.important_declarations, + &mut current.declarations.important_declarations, + ), + ] { + let mut indices = HashMap::new(); + for (index, property) in previous.iter().enumerate() { + if let Property::Custom(custom) = property { + indices.insert(custom.name.clone(), index); + } + } + for property in current.drain(..) { + if let Property::Custom(custom) = &property { + if let Some(index) = indices.get(&custom.name) { + previous[*index] = property; + continue; + } + indices.insert(custom.name.clone(), previous.len()); + } + previous.push(property); + } + } + previous.vendor_prefix |= current.vendor_prefix; + true +} + fn extract_common_custom_properties<'i, T>( previous: &mut StyleRule<'i, T>, current: &mut StyleRule<'i, T>, @@ -1017,8 +1096,7 @@ fn extract_common_custom_properties<'i, T>( if !previous.rules.0.is_empty() || !current.rules.0.is_empty() || previous.loc.source_index != current.loc.source_index - || previous.vendor_prefix != current.vendor_prefix - || previous.vendor_prefix.intersects(!VendorPrefix::None) + || (previous.vendor_prefix | current.vendor_prefix).intersects(!VendorPrefix::None) { return None; } @@ -1118,7 +1196,7 @@ fn extract_common_custom_properties<'i, T>( selectors.0.extend(current.selectors.0.iter().cloned()); Some(StyleRule { selectors, - vendor_prefix: previous.vendor_prefix, + vendor_prefix: previous.vendor_prefix | current.vendor_prefix, declarations, rules: CssRuleList(vec![]), loc: previous.loc, diff --git a/src/selector.rs b/src/selector.rs index da4723f36..086795fc4 100644 --- a/src/selector.rs +++ b/src/selector.rs @@ -69,7 +69,15 @@ pub(crate) fn has_nesting(selector: &Selector) -> bool { type Impl = Selectors; fn visit_simple_selector(&mut self, component: &Component<'i>) -> bool { - !matches!(component, Component::Nesting) + match component { + Component::Nesting => false, + // SelectorVisitor does not descend into these arguments by default. + Component::Has(selectors) | Component::Any(_, selectors) => selectors.iter().all(|s| s.visit(self)), + Component::PseudoElement( + PseudoElement::CueFunction { selector } | PseudoElement::CueRegionFunction { selector }, + ) => Selector::visit(selector, self), + _ => true, + } } } @@ -1946,7 +1954,15 @@ pub(crate) fn is_compatible(selectors: &[Selector], targets: Targets) -> bool { continue; } - Component::Scope | Component::Host(_) | Component::Slotted(_) => Feature::Shadowdomv1, + Component::Host(Some(selector)) | Component::Slotted(selector) => { + // These arguments are not forgiving: an unsupported selector inside + // the function invalidates the entire outer selector list as well. + if !is_compatible(std::slice::from_ref(selector), targets) { + return false; + } + Feature::Shadowdomv1 + } + Component::Scope | Component::Host(None) => Feature::Shadowdomv1, Component::Part(_) => Feature::PartPseudo, @@ -2027,7 +2043,15 @@ pub(crate) fn is_compatible(selectors: &[Selector], targets: Targets) -> bool { PseudoElement::Marker => Feature::MarkerPseudo, PseudoElement::Backdrop(prefix) if *prefix == VendorPrefix::None => Feature::Dialog, PseudoElement::Cue => Feature::Cue, - PseudoElement::CueFunction { selector: _ } => Feature::CueFunction, + PseudoElement::CueFunction { selector } => { + if selector.has_combinator() + || selector.has_pseudo_element() + || !is_compatible(std::slice::from_ref(selector), targets) + { + return false; + } + Feature::CueFunction + } PseudoElement::ViewTransition | PseudoElement::ViewTransitionNew { .. } | PseudoElement::ViewTransitionOld { .. } diff --git a/tests/cssnano/LICENSE b/tests/cssnano/LICENSE new file mode 100644 index 000000000..bec23f0e5 --- /dev/null +++ b/tests/cssnano/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) Ben Briggs (https://beneb.info) + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. From 8e481afd402a4c4e23c797e6165c859c27008b80 Mon Sep 17 00:00:00 2001 From: Cong-Cong Pan Date: Tue, 8 Sep 2026 15:32:21 +0800 Subject: [PATCH 3/3] chore: keep fixture license notice inline --- src/lib.rs | 24 +++++++++++++++++++++++- tests/cssnano/LICENSE | 22 ---------------------- 2 files changed, 23 insertions(+), 23 deletions(-) delete mode 100644 tests/cssnano/LICENSE diff --git a/src/lib.rs b/src/lib.rs index f21807145..bc523d919 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10395,8 +10395,30 @@ mod tests { // Cases adapted from cssnano/postcss-merge-rules (MIT), with declarations // changed to custom properties where appropriate: - // The upstream fixture license is preserved in tests/cssnano/LICENSE. // https://github.com/cssnano/cssnano/blob/13b2373173ce49c78b8a2015d8c3f6b025ccfc3e/packages/postcss-merge-rules/test/index.js + // + // Copyright (c) Ben Briggs (https://beneb.info) + // + // Permission is hereby granted, free of charge, to any person + // obtaining a copy of this software and associated documentation + // files (the "Software"), to deal in the Software without + // restriction, including without limitation the rights to use, + // copy, modify, merge, publish, distribute, sublicense, and/or sell + // copies of the Software, and to permit persons to whom the + // Software is furnished to do so, subject to the following + // conditions: + // + // The above copyright notice and this permission notice shall be + // included in all copies or substantial portions of the Software. + // + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + // EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + // OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + // NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + // HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + // OTHER DEALINGS IN THE SOFTWARE. #[test] fn test_merge_common_custom_properties_cssnano() { // Different declaration order (cssnano test/index.js:40). diff --git a/tests/cssnano/LICENSE b/tests/cssnano/LICENSE deleted file mode 100644 index bec23f0e5..000000000 --- a/tests/cssnano/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -Copyright (c) Ben Briggs (https://beneb.info) - -Permission is hereby granted, free of charge, to any person -obtaining a copy of this software and associated documentation -files (the "Software"), to deal in the Software without -restriction, including without limitation the rights to use, -copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following -conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE.