From 9e29139b0337dd500d46f2641644e01a3093ceed Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 21:16:20 +0900 Subject: [PATCH 01/14] fix(extractor): let a later css() part replace an earlier one's declarations css(a, b) composing classes whose styles the build knows, bound to css() in the file or exported by another module, merges their atoms per property, selector, breakpoint and layer, conditions included, instead of joining classes whose winner the stylesheet order picked. vanilla-extract style([...]) passes each composed style as its own argument, keeping a style composed again later. Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_compose_known_styles.json | 7 + libs/extractor/src/composition.rs | 360 ++++++++++++++++++ libs/extractor/src/imported_constants.rs | 181 ++++++--- libs/extractor/src/lib.rs | 237 ++++++++++++ ...ts__compose_rules_the_module_computes.snap | 12 +- ...__composed_css_keeps_composed_classes.snap | 2 +- ...or__tests__evaluate_build_time_values.snap | 2 +- libs/extractor/src/style_values.rs | 42 +- libs/extractor/src/utils.rs | 5 +- libs/extractor/src/vanilla_extract.rs | 73 ++-- libs/extractor/src/visit.rs | 326 +++++++++++++++- 11 files changed, 1159 insertions(+), 88 deletions(-) create mode 100644 .changepacks/changepack_log_compose_known_styles.json create mode 100644 libs/extractor/src/composition.rs diff --git a/.changepacks/changepack_log_compose_known_styles.json b/.changepacks/changepack_log_compose_known_styles.json new file mode 100644 index 00000000..6aa4c50d --- /dev/null +++ b/.changepacks/changepack_log_compose_known_styles.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "css(a, b) composing classes whose styles the build knows (a const bound to css() in the file, or a css() result another module exports with a rule object every value of which is known) merges their styles: a later part's declaration replaces an earlier one's for the same property, selector, breakpoint and layer, also under conditions (css(base, cond && danger)) and for ||/?? parts, where the classes used to be joined and the stylesheet order picked the winner. vanilla-extract style([a, b]) passes each composed style as its own argument, so the later one wins, and a style composed again later (style([a, b, a])) is kept. Classes the build does not know (strings, props, CSS Modules) are kept as they are", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs new file mode 100644 index 00000000..7b73eafd --- /dev/null +++ b/libs/extractor/src/composition.rs @@ -0,0 +1,360 @@ +//! Styles composed in one place, such as `css(base, cond && danger)`: when the +//! build knows the parts, a later part's declaration replaces an earlier one's +//! for the same property, selector, breakpoint and layer. Atomic classes alone +//! cannot express that, because which of two classes wins depends on their +//! order in the stylesheet, not on the order they were composed in. + +use css::style_selector::StyleSelector; +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::Expression; +use oxc_ast::builder::AstBuilder; + +use crate::{ExtractStyleProp, ExtractStyleValue}; + +/// What a declaration competes on: property, selector, breakpoint and layer +#[derive(Clone, PartialEq, Eq)] +struct CascadeKey { + property: String, + selector: Option, + level: u8, + layer: Option, +} + +impl CascadeKey { + fn of(value: &ExtractStyleValue) -> Self { + let (property, selector, level, layer) = match value { + ExtractStyleValue::Static(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + ExtractStyleValue::Dynamic(style) => ( + style.property(), + style.selector(), + style.level(), + style.layer(), + ), + // A preset: the only other value a style prop holds + _ => ("typography", None, 0, None), + }; + Self { + property: property.to_string(), + selector: selector.cloned(), + level, + layer: layer.map(ToString::to_string), + } + } +} + +/// What a key resolves to +enum Choice<'a> { + Empty, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// What a part does to a key +enum Overlay<'a> { + /// Leaves what came before + Keep, + Atom(ExtractStyleValue), + Conditional { + test: Expression<'a>, + consequent: Box>, + alternate: Box>, + }, +} + +/// The parts composed so far, in source order: a key a later part sets moves +/// to the end, as when merging objects +#[derive(Default)] +pub struct Composition<'a> { + entries: Vec<(CascadeKey, Choice<'a>)>, + /// Styles whose properties the build cannot pair up, kept as they are + unkeyed: Vec>, +} + +impl<'a> Composition<'a> { + /// A part the build knows completely + pub fn apply(&mut self, ast_builder: &AstBuilder<'a>, props: Vec>) { + let mut overlays: Vec<(CascadeKey, Overlay<'a>)> = Vec::new(); + for prop in props { + self.overlays(ast_builder, prop, &mut overlays); + } + for (key, overlay) in overlays { + self.overlay(ast_builder, key, overlay); + } + } + + /// A part applying only while `test` holds, or `alternate` otherwise + pub fn apply_conditional( + &mut self, + ast_builder: &AstBuilder<'a>, + test: &Expression<'a>, + consequent: Vec>, + alternate: Vec>, + ) { + let prop = ExtractStyleProp::Conditional { + condition: test.clone_in(ast_builder.allocator()), + consequent: Some(Box::new(ExtractStyleProp::StaticArray(consequent))), + alternate: Some(Box::new(ExtractStyleProp::StaticArray(alternate))), + }; + self.apply(ast_builder, vec![prop]); + } + + /// Each key a prop sets, with what it sets it to + fn overlays( + &mut self, + ast_builder: &AstBuilder<'a>, + prop: ExtractStyleProp<'a>, + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + ) { + match prop { + ExtractStyleProp::Static(value) => { + push_overlay(overlays, CascadeKey::of(&value), Overlay::Atom(value)); + } + ExtractStyleProp::StaticArray(props) => { + for prop in props { + self.overlays(ast_builder, prop, overlays); + } + } + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } if [&consequent, &alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)) => + { + let mut sides = [Vec::new(), Vec::new()]; + for (side, prop) in sides.iter_mut().zip([consequent, alternate]) { + if let Some(prop) = prop { + self.overlays(ast_builder, *prop, side); + } + } + let [mut consequent, mut alternate] = sides; + let mut keys: Vec = Vec::new(); + for (key, _) in consequent.iter().chain(&alternate) { + if !keys.contains(key) { + keys.push(key.clone()); + } + } + for key in keys { + let consequent = take_overlay(&mut consequent, &key); + let alternate = take_overlay(&mut alternate, &key); + push_overlay( + overlays, + key, + Overlay::Conditional { + test: condition.clone_in(ast_builder.allocator()), + consequent: Box::new(consequent), + alternate: Box::new(alternate), + }, + ); + } + } + prop => self.unkeyed.push(prop), + } + } + + fn overlay(&mut self, ast_builder: &AstBuilder<'a>, key: CascadeKey, overlay: Overlay<'a>) { + let previous = self + .entries + .iter() + .position(|(existing, _)| *existing == key) + .map_or(Choice::Empty, |index| self.entries.remove(index).1); + let choice = resolve(ast_builder, overlay, previous); + self.entries.push((key, choice)); + } + + /// The composed styles, for class names and the stylesheet + #[must_use] + pub fn into_props(self) -> Vec> { + self.entries + .into_iter() + .filter_map(|(_, choice)| into_prop(choice)) + .chain(self.unkeyed) + .collect() + } + + /// The composed atoms when no condition chooses between them + #[must_use] + pub fn unconditional(&self) -> Option> { + if !self.unkeyed.is_empty() { + return None; + } + let mut values = Vec::new(); + for (_, choice) in &self.entries { + match choice { + Choice::Atom(value) => values.push(value.clone()), + Choice::Empty => {} + Choice::Conditional { .. } => return None, + } + } + Some(values) + } +} + +/// Styles a part of `css(...)` gives +pub enum KnownStyles<'a> { + /// The styles of a `css()` class the file binds + Known(Vec), + /// A rule object + Rules(Expression<'a>), +} + +/// A side of a condition among the parts of `css(...)` +pub enum KnownSide<'a> { + Styles(Vec>), + /// A class the build does not know the styles of + Class(Expression<'a>), + Empty, +} + +/// A part of `css(...)`, in source order +pub enum KnownPart<'a> { + Styles(Vec>), + Conditional { + test: Expression<'a>, + consequent: Vec>, + alternate: Vec>, + }, + Class(Expression<'a>), +} + +/// `prop`'s styles at `order`, unless one sets its own +pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) { + match prop { + ExtractStyleProp::Static(value) => value.set_style_order(order), + ExtractStyleProp::StaticArray(props) => { + for prop in props { + set_prop_order(prop, order); + } + } + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + set_prop_order(side, order); + } + } + ExtractStyleProp::Enum { map, .. } => { + for prop in map.values_mut().flatten() { + set_prop_order(prop, order); + } + } + ExtractStyleProp::MemberExpression { map, .. } => { + for prop in map.values_mut() { + set_prop_order(prop, order); + } + } + // Class names the code gives, and styles reported as errors + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} + } +} + +/// Whether every style `prop` holds has a key, so a condition around it can be +/// applied key by key +fn keyed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(_) => true, + ExtractStyleProp::StaticArray(props) => props.iter().all(keyed), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => [consequent, alternate] + .into_iter() + .flatten() + .all(|side| keyed(side)), + _ => false, + } +} + +/// `overlay` replacing what `overlays` already sets for the key: a part +/// declaring a property twice keeps the later declaration +fn push_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: CascadeKey, + overlay: Overlay<'a>, +) { + overlays.retain(|(existing, _)| *existing != key); + overlays.push((key, overlay)); +} + +fn take_overlay<'a>( + overlays: &mut Vec<(CascadeKey, Overlay<'a>)>, + key: &CascadeKey, +) -> Overlay<'a> { + overlays + .iter() + .position(|(existing, _)| existing == key) + .map_or(Overlay::Keep, |index| overlays.remove(index).1) +} + +/// What `overlay` makes of `previous` +fn resolve<'a>( + ast_builder: &AstBuilder<'a>, + overlay: Overlay<'a>, + previous: Choice<'a>, +) -> Choice<'a> { + match overlay { + Overlay::Keep => previous, + Overlay::Atom(value) => Choice::Atom(value), + Overlay::Conditional { + test, + consequent, + alternate, + } => { + let copy = copy_choice(ast_builder, &previous); + Choice::Conditional { + test, + consequent: Box::new(resolve(ast_builder, *consequent, previous)), + alternate: Box::new(resolve(ast_builder, *alternate, copy)), + } + } + } +} + +fn copy_choice<'a>(ast_builder: &AstBuilder<'a>, choice: &Choice<'a>) -> Choice<'a> { + match choice { + Choice::Empty => Choice::Empty, + Choice::Atom(value) => Choice::Atom(value.clone()), + Choice::Conditional { + test, + consequent, + alternate, + } => Choice::Conditional { + test: test.clone_in(ast_builder.allocator()), + consequent: Box::new(copy_choice(ast_builder, consequent)), + alternate: Box::new(copy_choice(ast_builder, alternate)), + }, + } +} + +fn into_prop(choice: Choice<'_>) -> Option> { + match choice { + Choice::Empty => None, + Choice::Atom(value) => Some(ExtractStyleProp::Static(value)), + Choice::Conditional { + test, + consequent, + alternate, + } => { + let (consequent, alternate) = (into_prop(*consequent), into_prop(*alternate)); + (consequent.is_some() || alternate.is_some()).then(|| ExtractStyleProp::Conditional { + condition: test, + consequent: consequent.map(Box::new), + alternate: alternate.map(Box::new), + }) + } + } +} diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1de..83f065a6 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -21,8 +21,13 @@ use oxc_syntax::operator::BinaryOperator; use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::composition::{Composition, set_prop_order}; +use crate::extractor::ExtractResult; +use crate::extractor::extract_style_from_expression::{ + LiteralHandling, extract_style_from_expression, +}; use crate::stylex::StylexFunction; -use crate::{ExtractOption, ModuleResolver}; +use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; #[derive(Clone, Debug)] enum Constant { @@ -44,8 +49,9 @@ enum Constant { /// The class a `StyleX` theme applies Theme(String), /// What another style API gives: a class, a component or a keyframes - /// name, never rules - Style, + /// name, never rules; for a `css()` class, the styles behind it when + /// they are known + Style(Option>>), /// An object or array code changes, or a value read from one Changed(Rc), } @@ -154,6 +160,8 @@ pub(crate) struct Inlined { pub dependencies: BTreeSet, pub stylex_vars: FxHashMap>, pub stylex_themes: FxHashMap, + /// The styles behind imported `css()` classes + pub css_styles: FxHashMap>, pub unknown: Unknown, pub changed: Changed, } @@ -471,6 +479,11 @@ pub(crate) fn inline_constants<'a>( Constant::Theme(class) => { inlined.stylex_themes.insert(name.clone(), class.clone()); } + Constant::Style(Some(styles)) => { + inlined + .css_styles + .insert(name.clone(), styles.as_ref().clone()); + } _ => {} } for symbol in bindings.get(name.as_str()).into_iter().flatten() { @@ -1032,6 +1045,58 @@ impl<'p, 'a> ModuleScope<'p, 'a> { }) } + /// The styles behind `css(rules)`, the package's own `css` given one + /// rule object every value of which is known, as the module's class names + /// do not tell them + fn css_styles( + &mut self, + modules: &mut Modules<'_>, + call: &oxc_ast::ast::CallExpression<'_>, + ) -> Option>> { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let (source, Imported::Named(export)) = self.imports.get(callee.name.as_str())? else { + return None; + }; + if export != "css" || !source.starts_with(modules.option.package.as_str()) { + return None; + } + let [argument] = call.arguments.as_slice() else { + return None; + }; + let rules = self.evaluate(modules, argument.as_expression()?)?; + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut rules = match constant_literal(&builder, &rules, true)? { + rules @ Expression::ObjectExpression(_) => rules, + _ => return None, + }; + let ExtractResult { + mut styles, + style_order, + .. + } = extract_style_from_expression( + &builder, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + if crate::utils::runtime_value(&styles).is_some() { + return None; + } + if let Some(order) = style_order { + for prop in &mut styles { + set_prop_order(prop, order); + } + } + let mut composition = Composition::default(); + composition.apply(&builder, styles); + composition.unconditional().map(Rc::new) + } + fn is_style_import(&self, option: &ExtractOption, name: &str) -> bool { self.style_imports.contains(name) || self.style_names.contains(name) @@ -1465,13 +1530,15 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } fold_math(callee.property.name.as_str(), &arguments) } - callee if self.is_style_api(modules, callee) => Some(Constant::Style), + callee if self.is_style_api(modules, callee) => { + Some(Constant::Style(self.css_styles(modules, call))) + } _ => self.evaluate_stylex(modules, call), }, Expression::TaggedTemplateExpression(tagged) if self.is_style_api(modules, &tagged.tag) => { - Some(Constant::Style) + Some(Constant::Style(None)) } Expression::TSAsExpression(inner) => self.evaluate(modules, &inner.expression), Expression::TSSatisfiesExpression(inner) => self.evaluate(modules, &inner.expression), @@ -1837,55 +1904,7 @@ impl<'a> Inline<'_, 'a> { } fn literal(&self, constant: &Constant) -> Option> { - let builder = self.ast_builder; - match constant { - Constant::String(value) => Some(Expression::new_string_literal( - SPAN, - Str::from_in(value.as_str(), builder.allocator()), - None, - builder, - )), - Constant::Number(value) => Some(Expression::new_numeric_literal( - SPAN, - *value, - None, - NumberBase::Decimal, - builder, - )), - Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), - Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), - Constant::Record(entries) if self.objects => { - let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); - for (key, value) in entries.iter() { - properties.push(ObjectPropertyKind::new_object_property( - SPAN, - oxc_ast::ast::PropertyKind::Init, - oxc_ast::ast::PropertyKey::StringLiteral( - oxc_ast::ast::StringLiteral::boxed( - SPAN, - Str::from_in(key.as_str(), builder.allocator()), - None, - builder, - ), - ), - self.literal(value)?, - false, - false, - false, - builder, - )); - } - Some(Expression::new_object_expression(SPAN, properties, builder)) - } - Constant::Array(values) if self.objects => { - let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); - for value in values.iter() { - elements.push(self.literal(value)?.into()); - } - Some(Expression::new_array_expression(SPAN, elements, builder)) - } - _ => None, - } + constant_literal(self.ast_builder, constant, self.objects) } fn reading_objects(&mut self, objects: bool, visit: impl FnOnce(&mut Self) -> T) -> T { @@ -1904,6 +1923,60 @@ impl<'a> Inline<'_, 'a> { } } +/// `constant` written as a literal, objects and arrays too when `objects` +fn constant_literal<'a>( + builder: &AstBuilder<'a>, + constant: &Constant, + objects: bool, +) -> Option> { + match constant { + Constant::String(value) => Some(Expression::new_string_literal( + SPAN, + Str::from_in(value.as_str(), builder.allocator()), + None, + builder, + )), + Constant::Number(value) => Some(Expression::new_numeric_literal( + SPAN, + *value, + None, + NumberBase::Decimal, + builder, + )), + Constant::Null => Some(Expression::new_null_literal(SPAN, builder)), + Constant::Bool(value) => Some(Expression::new_boolean_literal(SPAN, *value, builder)), + Constant::Record(entries) if objects => { + let mut properties = oxc_allocator::Vec::with_capacity_in(entries.len(), builder); + for (key, value) in entries.iter() { + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + oxc_ast::ast::PropertyKey::StringLiteral(oxc_ast::ast::StringLiteral::boxed( + SPAN, + Str::from_in(key.as_str(), builder.allocator()), + None, + builder, + )), + constant_literal(builder, value, objects)?, + false, + false, + false, + builder, + )); + } + Some(Expression::new_object_expression(SPAN, properties, builder)) + } + Constant::Array(values) if objects => { + let mut elements = oxc_allocator::Vec::with_capacity_in(values.len(), builder); + for value in values.iter() { + elements.push(constant_literal(builder, value, objects)?.into()); + } + Some(Expression::new_array_expression(SPAN, elements, builder)) + } + _ => None, + } +} + impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_expression(&mut self, expression: &mut Expression<'a>) { if self.styles { diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36..6ffada20 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1,6 +1,7 @@ mod as_visit; mod build_time_values; mod component; +mod composition; mod css_utils; pub mod extract_style; mod extractor; @@ -437,6 +438,7 @@ fn extract_source( if global { None } else { Some(bucket) }, ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); + visitor.import_css(inlined.css_styles); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.visit_program(&mut program); @@ -14553,6 +14555,241 @@ const Button = styled.button({ bg: 'red' }) )); } + /// `code` extracted with readable class names + fn readable_code(code: &str) -> String { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract( + "test.tsx", + code, + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ); + css::debug::set_debug(false); + match output { + Ok(output) => output.code, + Err(error) => error.to_string(), + } + } + + // A later part of `css(...)` replaces an earlier part's declaration of the + // same property, selector, breakpoint and layer, whatever the stylesheet + // order of their classes + #[rstest] + #[case( + "css(yellow, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(azure, yellow)", + r#""background-0-black--255 color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css({ color: 'green' }, yellow)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(yellow, { color: 'green' })", + r#""color-0-red-_a__c_hover-255 color-0-green--255""# + )] + #[case( + "css([yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(...[yellow, azure])", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, null, undefined, false, azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on && azure)", + r#"`color-0-red-_a__c_hover-255 ${on ? "background-0-black--255" : ""} ${on ? "color-0-azure--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(on ? yellow : azure)", + r#"`${on ? "color-0-red-_a__c_hover-255" : ""} ${on ? "color-0-yellow--255" : "color-0-azure--255"} ${on ? "" : "background-0-black--255"}`"# + )] + #[case( + "css(yellow, on ? { color: 'pink' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, { color: on ? 'pink' : 'teal' })", + r#"`color-0-red-_a__c_hover-255 ${on ? "color-0-pink--255" : "color-0-teal--255"}`"# + )] + #[case( + "css(yellow, ext, azure)", + r"`color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255 ${ext}`" + )] + #[case( + "css(yellow, on ? 'plain' : azure)", + r#"`${on ? "plain" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(azure, wide)", + r#""background-0-black--255 color-0-a1--255 color-1-b1--255""# + )] + #[case( + "css(wide, { color: [null, 'x2'] })", + r#""color-0-a1--255 color-1-x2--255""# + )] + #[case( + "css(yellow, ordered)", + r#""color-0-red-_a__c_hover-255 color-0-navy--3""# + )] + #[case("css(heading, body)", r#""typo-body""#)] + #[case( + "css(chained, { color: 'gold' })", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-gold--255""# + )] + #[case( + "css(yellow, { color: { a: 'red', b: 'blue' }[size] })", + r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{" + )] + #[case( + "css(yellow, on ? { color: { a: 'red' }[size] } : null)", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? { "a": "color-0-red--255" }[size] || "" : ""}`"# + )] + #[case( + "css(yellow, on || azure)", + r#"`${on ? typeof on === "string" ? on : "" : ""} ${`color-0-red-_a__c_hover-255 ${on ? "" : "background-0-black--255"} ${on ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, ext ?? azure)", + r#"`${ext != null ? typeof ext === "string" ? ext : "" : ""} ${`color-0-red-_a__c_hover-255 ${ext != null ? "" : "background-0-black--255"} ${ext != null ? "color-0-yellow--255" : "color-0-azure--255"}`}`"# + )] + #[case( + "css(yellow, null ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] + #[case( + "css(azure || yellow)", + r#""background-0-black--255 color-0-azure--255""# + )] + #[case( + "css(yellow, on || 'plain')", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${on ? typeof on === "string" ? on : "" : "plain"}`"# + )] + #[case( + "css(yellow, { color: on ? 'a' : 'b', m: [1, 2], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 margin-0-4px--2 margin-1-8px--2 ${on ? "color-0-a--2" : "color-0-b--2"}`"# + )] + #[case( + "css(yellow, { color: { a: 'x' }[size], styleOrder: 2 })", + r#"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{ "a": "color-0-x--2" }[size] || ""}`"# + )] + #[case( + "css(yellow, on ? { color: other ? 'a' : 'b' } : null)", + r#"`color-0-red-_a__c_hover-255 ${on ? other ? "color-0-a--255" : "color-0-b--255" : "color-0-yellow--255"}`"# + )] + #[case( + "css(yellow, fade)", + r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${fade}`" + )] + #[case( + "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", + "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`" + )] + #[serial] + fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { + let code = readable_code(&format!( + "import {{css, keyframes}} from '@devup-ui/core' +const yellow = css({{ color: 'yellow', _hover: {{ color: 'red' }} }}) +const azure = css({{ color: 'azure', bg: 'black' }}) +const wide = css({{ color: ['a1', 'b1'] }}) +const ordered = css({{ color: 'navy', styleOrder: 3 }}) +const heading = css({{ typography: 'heading' }}) +const body = css({{ typography: 'body' }}) +const chained = css(yellow, azure) +const fade = keyframes({{ from: {{ opacity: 0 }} }}) +export const result = {call} +" + )); + assert!( + code.contains(&format!("export const result = {expected}")), + "{code}" + ); + } + + #[test] + #[serial] + fn test_css_composing_reports_runtime_values() { + let code = readable_code( + "import {css} from '@devup-ui/core' +const base = css({ color: 'red' }) +export const a = css(base, { color: tone }) +export const b = css(base, { [key]: 'x', styleOrder: 2 })", + ); + assert!(code.contains("`css()` cannot use `tone`"), "{code}"); + } + + // The styles of a `css()` class another module exports compose as well + #[test] + #[serial] + fn test_css_composes_imported_classes() { + reset_class_map(); + reset_file_map(); + let modules: &[(&str, &str)] = &[( + "/src/styles.ts", + "import { css, keyframes } from '@devup-ui/react'; +import * as Devup from '@devup-ui/react'; +const brand = 'teal'; +export const base = css({ color: brand, _hover: { color: 'red' } }); +export const danger = css({ color: 'crimson' }); +export const twice = css({ color: 'a' }, { m: 1 }); +export const text = css`color: blue;`; +export const runtime = css({ color: globalThis.tone }); +export const listed = css([{ color: 'b' }]); +export const spaced = Devup.css({ color: 'c' }); +export const fade = keyframes({ from: { opacity: 0 } });", + )]; + let resolver = memory_resolver(modules); + css::debug::set_debug(true); + let output = extract_with_modules( + "/src/App.tsx", + "import { css } from '@devup-ui/react'; +import { base, danger, twice, text, runtime, listed, spaced, fade } from './styles'; +export const a = css(base, danger); +export const b = css(danger, base, { m: 1 }); +export const c = css(twice, text, runtime, listed, spaced, fade);", + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/css".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + false, + &resolver, + ) + .unwrap(); + css::debug::set_debug(false); + for expected in [ + r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#, + r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#, + "export const c = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;", + ] { + assert!( + output.code.contains(expected), + "{expected} in {}", + output.code + ); + } + } + #[test] #[serial] fn test_tailwind_classname_extraction() { diff --git a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap index 5325a6ce..a07f7aea 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap @@ -64,6 +64,16 @@ ToBTreeSet { layer: None, }, ), + Static( + ExtractStaticStyle { + property: "padding", + value: "4px", + level: 0, + selector: None, + style_order: None, + layer: None, + }, + ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-d\";\nexport const b = `a-e ${card}`;\nexport const c = `named x ${`y`}`;\nexport const d = ({ style, className, ...rest }) =>
;\nexport const e = ({ style, className, ...rest }) => ;\nexport const f = (__devupProps) => (({ style, className, ...rest }) =>

)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n});\nexport const g = (on) => `a-d a-a ${on ? \"\" : card} ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-b\";\nexport const i = \"a-d\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-c ${emotionClass}`;\nexport const n = ;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-e\";\nexport const b = \"a-a a-f\";\nexport const c = `named x ${`y`}`;\nexport const d = ({ style, className, ...rest }) =>

;\nexport const e = ({ style, className, ...rest }) => ;\nexport const f = (__devupProps) => (({ style, className, ...rest }) =>

)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n});\nexport const g = (on) => `a-e a-b ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-c\";\nexport const i = \"a-e\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-d ${emotionClass}`;\nexport const n = ;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap index 12974320..a6019f8f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__composed_css_keeps_composed_classes.snap @@ -35,5 +35,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = \"a b\";\nexport const b = \"a extra c\";\nexport const c = \"a\";\nexport const d = cond ? \"a\" : \"\";\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst base = \"a\";\nexport const a = \"a b\";\nexport const b = \"extra a c\";\nexport const c = \"a\";\nexport const d = cond ? \"a\" : \"\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap index 290d709f..315ad4ea 100644 --- a/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap @@ -229,5 +229,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { PRIMARY, SPACE } from \"./color\";\nimport { card } from \"./styles\";\nimport { BEFORE } from \"./fails\";\nexport const SIZE = 4;\nexport default function twice(n: number) {\n\treturn n * 2;\n}\nenum Level {\n\tLow = 1,\n\tHigh = twice(2)\n}\nfunction darken(amount: number, color: string): string {\n\treturn color === PRIMARY ? `darker(${amount})` : color;\n}\nconst LIGHT = darken(.2, PRIMARY);\nconst color = darken(.3, PRIMARY);\nconst make = (size: number) => ({\n\tp: size,\n\t_hover: { color: LIGHT }\n});\nconst label = (text: string) => text.toUpperCase().padStart(4, \"-\");\nexport const a = \"a-n a-o a-p a-q a-e a-r\";\nexport const b = \"a-s\";\n;\nconst styles = { \"base\": \"a-t\" };\nexport const c =

;\nexport const d = \"a-u a-v a-w a-x a-y a-i\";\nexport const e = (dark: boolean) => `a-_ ${dark ? \"a-t\" : \"a-j\"} ${dark ? \"a-z\" : \"\"}`;\nexport const f = `a-aa ${card}`;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { PRIMARY, SPACE } from \"./color\";\nimport { card } from \"./styles\";\nimport { BEFORE } from \"./fails\";\nexport const SIZE = 4;\nexport default function twice(n: number) {\n\treturn n * 2;\n}\nenum Level {\n\tLow = 1,\n\tHigh = twice(2)\n}\nfunction darken(amount: number, color: string): string {\n\treturn color === PRIMARY ? `darker(${amount})` : color;\n}\nconst LIGHT = darken(.2, PRIMARY);\nconst color = darken(.3, PRIMARY);\nconst make = (size: number) => ({\n\tp: size,\n\t_hover: { color: LIGHT }\n});\nconst label = (text: string) => text.toUpperCase().padStart(4, \"-\");\nexport const a = \"a-n a-o a-p a-q a-e a-r\";\nexport const b = \"a-s\";\n;\nconst styles = { \"base\": \"a-t\" };\nexport const c =
;\nexport const d = \"a-u a-v a-w a-x a-y a-i\";\nexport const e = (dark: boolean) => `a-_ ${dark ? \"a-t\" : \"a-j\"} ${dark ? \"a-z\" : \"\"}`;\nexport const f = \"a-aa\";\n", } diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 77dc8f56..74981784 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -11,11 +11,15 @@ use oxc_span::SPAN; use oxc_syntax::symbol::SymbolId; use rustc_hash::FxHashMap; +use crate::ExtractStyleValue; + /// The name a style API call gives pub enum StyleValue { /// A class `css()` gives: in CSS text it is a mixin, composed rather than - /// written as text, so it is read only outside CSS text - Class(String), + /// written as text, so it is read only outside CSS text. The styles behind + /// it, when the build knows them, let a later style composed with it + /// replace its declarations. + Class(String, Option>), /// A name `keyframes()` gives Keyframes(String), } @@ -24,6 +28,8 @@ pub enum StyleValue { pub struct StyleValues { scoping: Option, values: FxHashMap, + /// The styles behind `css()` classes the file imports, by binding + imported: FxHashMap>, } impl StyleValues { @@ -31,9 +37,14 @@ impl StyleValues { Self { scoping: Some(scoping), values: FxHashMap::default(), + imported: FxHashMap::default(), } } + pub fn import(&mut self, imported: FxHashMap>) { + self.imported = imported; + } + /// The `const` `id` binds, whose value no code changes pub fn constant(&self, id: &BindingPattern<'_>) -> Option { let symbol = id.get_binding_identifier()?.symbol_id.get()?; @@ -48,6 +59,29 @@ impl StyleValues { self.values.insert(symbol, value); } + /// The styles behind the `css()` class `expression` reads + pub fn styles(&self, expression: &Expression<'_>) -> Option<&[ExtractStyleValue]> { + let Expression::Identifier(identifier) = expression else { + return None; + }; + let symbol = self + .scoping + .as_ref()? + .get_reference(identifier.reference_id.get()?) + .symbol_id()?; + let scoping = self.scoping.as_ref()?; + match self.values.get(&symbol) { + Some(StyleValue::Class(_, Some(styles))) => Some(styles), + Some(_) => None, + None => scoping + .symbol_flags(symbol) + .is_import() + .then(|| self.imported.get(scoping.symbol_name(symbol))) + .flatten() + .map(Vec::as_slice), + } + } + /// `expression` reading what the bindings recorded hold pub fn read_in<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { if let Some(mut reads) = self.reads(ast) { @@ -92,8 +126,8 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { .and_then(|symbol| self.values.get(&symbol)) { let value = match value { - StyleValue::Class(_) if self.in_text => return, - StyleValue::Class(value) | StyleValue::Keyframes(value) => value, + StyleValue::Class(..) if self.in_text => return, + StyleValue::Class(value, _) | StyleValue::Keyframes(value) => value, }; *it = Expression::new_string_literal( SPAN, diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 4fa94f72..1437fcb1 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -823,7 +823,10 @@ fn branch<'b, 'a>(expression: &'b Expression<'a>) -> Option> { /// `value` as a class: itself when it is a string, nothing otherwise, as the /// libraries skip `true` and other non-class values -fn string_class<'a>(ast_builder: &AstBuilder<'a>, value: &Expression<'a>) -> Expression<'a> { +pub(super) fn string_class<'a>( + ast_builder: &AstBuilder<'a>, + value: &Expression<'a>, +) -> Expression<'a> { if matches!( value, Expression::StringLiteral(_) | Expression::TemplateLiteral(_) diff --git a/libs/extractor/src/vanilla_extract.rs b/libs/extractor/src/vanilla_extract.rs index 9e736b09..5c80e370 100644 --- a/libs/extractor/src/vanilla_extract.rs +++ b/libs/extractor/src/vanilla_extract.rs @@ -1285,18 +1285,22 @@ fn inner_json(json: &str) -> &str { .trim() } -/// Styles `entry` composes, transitively and in order, each once +/// Styles `entry` composes, transitively and in order; a style composed again +/// later is listed again, as its declarations then win. `composing` holds the +/// styles being expanded, so a style composing itself stops. fn collect_bases<'a>( collected: &'a CollectedStyles, entry: &'a StyleEntry, - seen: &mut FxHashSet<&'a str>, + composing: &mut Vec<&'a str>, bases: &mut Vec<(&'a str, &'a StyleEntry)>, ) { for base in &entry.bases { - if seen.insert(base.as_str()) + if !composing.contains(&base.as_str()) && let Some(base_entry) = collected.styles.get(base) { - collect_bases(collected, base_entry, seen, bases); + composing.push(base); + collect_bases(collected, base_entry, composing, bases); + composing.pop(); bases.push((base.as_str(), base_entry)); } } @@ -1311,34 +1315,39 @@ fn composed_css( name: &str, entry: &StyleEntry, ) -> String { - let mut seen = FxHashSet::default(); - seen.insert(name); + let mut composing = vec![name]; let mut bases = Vec::new(); - collect_bases(collected, entry, &mut seen, &mut bases); + collect_bases(collected, entry, &mut composing, &mut bases); + // One argument per style: `css()` merges them, a later declaration + // replacing an earlier one, which one object holding both would not do let mut rules = Vec::with_capacity(bases.len() + 1); - let mut classes = Vec::new(); + let mut all_classes: Vec<&str> = Vec::new(); for (base_name, base) in &bases { let json = collected.resolve_json(&base.json, keyframes_names); - let inner = inner_json(&json); - if !inner.is_empty() { - rules.push(inner.to_string()); + if !inner_json(&json).is_empty() { + rules.push(json); + } + all_classes.extend(base.classes.iter().map(String::as_str)); + all_classes.extend(referenced_classes.get(base_name).copied()); + } + all_classes.extend(entry.classes.iter().map(String::as_str)); + all_classes.extend(referenced_classes.get(name).copied()); + let mut classes: Vec<&str> = Vec::with_capacity(all_classes.len()); + for class in all_classes { + if !classes.contains(&class) { + classes.push(class); } - classes.extend(base.classes.iter().map(String::as_str)); - classes.extend(referenced_classes.get(base_name).copied()); } - classes.extend(entry.classes.iter().map(String::as_str)); - classes.extend(referenced_classes.get(name).copied()); let own = collected.resolve_json(&entry.json, keyframes_names); let css = if bases.is_empty() { format!("css({own})") } else { - let inner = inner_json(&own); - if !inner.is_empty() { - rules.push(inner.to_string()); + if !inner_json(&own).is_empty() { + rules.push(own); } - format!("css({{{}}})", rules.join(",")) + format!("css({})", rules.join(", ")) }; if classes.is_empty() { css @@ -1669,7 +1678,7 @@ globalStyle(`${tone.primary} > span`, { fontWeight: 700 })" const _ve0 = css({"color":"blue"}) const _ve1 = css({"color":"red"}) + " f0__ve1" const _ve2 = css({"padding":"4px","content":"sm"}) -export const combined = css({"color":"red","margin":"1px"}) + " f0__ve1 external" +export const combined = css({"color":"red"}, {"margin":"1px"}) + " f0__ve1 external" globalCss({ ".f0__ve1 > span": {"fontWeight":700} }) export const tone = { "0": _ve0, "primary": _ve1 } export const space = { "sm": _ve2 } @@ -1677,6 +1686,26 @@ export const none = {}"# ); } + // Each composed style is an argument of its own, so a later one's + // declaration replaces an earlier one's, a style composed again included + #[test] + #[serial] + fn test_composition_keeps_later_declarations() { + assert_eq!( + generate( + "const first = style({ color: 'red', margin: 1 }) +const second = style({ color: 'blue' }) +export const later = style([first, second]) +export const again = style([first, second, first])" + ), + r#"import { css } from '@devup-ui/react' +export const again = css({"color":"red","margin":"1px"}, {"color":"blue"}, {"color":"red","margin":"1px"}) +const first = css({"color":"red","margin":"1px"}) +export const later = css({"color":"red","margin":"1px"}, {"color":"blue"}) +const second = css({"color":"blue"})"# + ); + } + #[test] #[serial] fn test_composition_is_transitive() { @@ -1694,8 +1723,8 @@ export const hover = style({ selectors: { [`${a}:hover &`]: { color: 'blue' } } ), r#"import { css } from '@devup-ui/react' const a = css({"color":"red"}) + " f0_a" -const b = css({"color":"red","margin":"2px"}) + " f0_a" -export const c = css({"color":"red","margin":"2px","padding":"3px"}) + " f0_a" +const b = css({"color":"red"}, {"margin":"2px"}) + " f0_a" +export const c = css({"color":"red"}, {"margin":"2px"}, {"padding":"3px"}) + " f0_a" const e = css({}) export const f = css({"color":"red"}) + " f0_a" export const g = css({}) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123..f41db587 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1,5 +1,6 @@ use crate::as_visit::As; use crate::component::ExportVariableKind; +use crate::composition::{KnownPart, KnownSide, KnownStyles, set_prop_order}; use crate::css_utils::{ TemplateStyles, css_to_style_template, keyframes_to_keyframes_style, optimize_css_block, template_css_text, @@ -52,6 +53,7 @@ use oxc_ast_visit::walk_mut::{ walk_variable_declarator, walk_variable_declarators, }; use oxc_syntax::number::NumberBase; +use oxc_syntax::operator::LogicalOperator; use strum::IntoEnumIterator; use crate::utils::{ @@ -60,8 +62,8 @@ use crate::utils::{ get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_pure, jsx_expression_to_style_order, key_error, readable_argument, readable_code, reads_directly, reads_spreads_once, reads_unknown, runtime_classes, runtime_value, runtime_value_error, - spread_error, stays_attribute, style_arguments, uncomposable_error, unplaced_error, - unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, + spread_error, stays_attribute, string_class, style_arguments, uncomposable_error, + unplaced_error, unreadable_styles, unwrap_syntax_only, unwrap_syntax_only_mut, }; use oxc_ast::builder::AstBuilder; use oxc_span::{GetSpan, SPAN}; @@ -179,6 +181,11 @@ pub struct DevupVisitor<'a> { runtime_types: usize, /// The classes and keyframes names the file binds to a `const` style_values: crate::style_values::StyleValues, + /// The styles of the last `css()` call giving a class, by where it starts, + /// for the `const` it initializes + css_styles: Option<(u32, Vec)>, + /// The styles behind `css()` classes the file imports, by binding + imported_css: FxHashMap>, /// What the build compiles away: the imports of the package it removes, /// and the bindings aliasing them, which only its calls and elements read compiled_names: FxHashSet, @@ -296,6 +303,278 @@ impl<'a> DevupVisitor<'a> { } } } + + /// `css(...)` composing a class whose styles the build knows: the parts' + /// styles merge, a later declaration replacing an earlier one. `None` when + /// no part is such a class, or a part is one composing does not read. + fn compose_known_styles(&mut self, call: &CallExpression<'a>) -> Option> { + let arguments: Vec<&Expression<'a>> = call + .arguments + .iter() + .map(|argument| match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }) + .collect(); + if !arguments + .iter() + .any(|argument| self.reads_known_styles(argument)) + { + return None; + } + let mut parts = Vec::new(); + for argument in &arguments { + self.known_parts(argument, &mut parts)?; + } + self.unknown_arguments("css", &call.arguments); + self.changed_arguments("css", &call.arguments); + + let mut composition = crate::composition::Composition::default(); + let mut classes = Vec::new(); + for part in parts { + match part { + KnownPart::Styles(side) => { + let props = self.part_props(call.span.start, side); + composition.apply(&self.ast, props); + } + KnownPart::Conditional { + test, + consequent, + alternate, + } => { + let consequent = self.part_props(call.span.start, consequent); + let alternate = self.part_props(call.span.start, alternate); + composition.apply_conditional(&self.ast, &test, consequent, alternate); + } + KnownPart::Class(mut class) => { + self.style_values.read_in(&self.ast, &mut class); + classes.push(class); + } + } + } + let known = composition.unconditional(); + let mut props = composition.into_props(); + // Class names come out in reverse, so they read in composing order + props.reverse(); + let class_name = + gen_class_names(&self.ast, &mut props, None, self.split_filename.as_deref()); + self.styles + .extend(props.into_iter().flat_map(ExtractStyleProp::into_extract)); + let composed_class = classes.is_empty(); + let result = + merge_expression_for_class_name(&self.ast, classes.into_iter().chain(class_name)) + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)); + if composed_class && let Some(known) = known { + self.css_styles = Some((call.span.start, known)); + } + Some(result) + } + + /// Whether `expression`, or a part of it, is a class whose styles the + /// build knows + fn reads_known_styles(&self, expression: &Expression<'a>) -> bool { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => array.elements.iter().any(|element| { + element + .as_expression() + .is_some_and(|element| self.reads_known_styles(element)) + }), + Expression::LogicalExpression(logical) => self.reads_known_styles(&logical.right), + Expression::ConditionalExpression(conditional) => { + self.reads_known_styles(&conditional.consequent) + || self.reads_known_styles(&conditional.alternate) + } + expression => self.style_values.styles(expression).is_some(), + } + } + + /// The parts `expression` composes, in order; `None` for a shape this + /// path does not read, which the general one then reads + fn known_parts( + &self, + expression: &Expression<'a>, + parts: &mut Vec>, + ) -> Option<()> { + let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.allocator()); + let (test, consequent, alternate) = match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => { + for element in &array.elements { + self.known_parts(element.as_expression()?, parts)?; + } + return Some(()); + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + (&logical.left, &logical.right, None) + } + // `left || right` and `left ?? right`: `left` while it applies, `right` + // otherwise + Expression::LogicalExpression(logical) => { + return match self.known_side(&logical.left)? { + KnownSide::Styles(side) => { + parts.push(KnownPart::Styles(side)); + Some(()) + } + KnownSide::Empty + if logical.operator == LogicalOperator::Coalesce + && matches!( + unwrap_syntax_only(&logical.left), + Expression::BooleanLiteral(_) + ) => + { + Some(()) + } + KnownSide::Empty => self.known_parts(&logical.right, parts), + KnownSide::Class(left) => { + let test = if logical.operator == LogicalOperator::Or { + clone(&left) + } else { + Expression::new_binary_expression( + SPAN, + clone(&left), + BinaryOperator::Inequality, + Expression::new_null_literal(SPAN, &self.ast), + &self.ast, + ) + }; + let right = self.known_side(&logical.right)?; + self.push_choice( + parts, + &test, + KnownSide::Class(string_class(&self.ast, &left)), + right, + ); + Some(()) + } + }; + } + Expression::ConditionalExpression(conditional) => ( + &conditional.test, + &conditional.consequent, + Some(&conditional.alternate), + ), + expression => { + match self.known_side(expression)? { + KnownSide::Styles(side) => parts.push(KnownPart::Styles(side)), + KnownSide::Class(class) => parts.push(KnownPart::Class(class)), + KnownSide::Empty => {} + } + return Some(()); + } + }; + let consequent = self.known_side(consequent)?; + let alternate = alternate.map_or(Some(KnownSide::Empty), |alternate| { + self.known_side(alternate) + })?; + self.push_choice(parts, test, consequent, alternate); + Some(()) + } + + /// `test ? consequent : alternate` among the parts, its classes and its + /// styles each choosing on their own + fn push_choice( + &self, + parts: &mut Vec>, + test: &Expression<'a>, + consequent: KnownSide<'a>, + alternate: KnownSide<'a>, + ) { + let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.allocator()); + let mut classes = [None, None]; + let mut styles = [None, None]; + for (index, side) in [consequent, alternate].into_iter().enumerate() { + match side { + KnownSide::Styles(side) => styles[index] = Some(side), + KnownSide::Class(class) => classes[index] = Some(class), + KnownSide::Empty => {} + } + } + if classes.iter().any(Option::is_some) { + let [consequent, alternate] = classes.map(|class| { + class.unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, &self.ast)) + }); + parts.push(KnownPart::Class(Expression::new_conditional_expression( + SPAN, + clone(test), + consequent, + alternate, + &self.ast, + ))); + } + if styles.iter().any(Option::is_some) { + let [consequent, alternate] = styles.map(Option::unwrap_or_default); + parts.push(KnownPart::Conditional { + test: clone(test), + consequent, + alternate, + }); + } + } + + fn known_side(&self, expression: &Expression<'a>) -> Option> { + let expression = unwrap_syntax_only(expression); + if let Some(styles) = self.style_values.styles(expression) { + return Some(KnownSide::Styles(vec![KnownStyles::Known(styles.to_vec())])); + } + match expression { + Expression::ObjectExpression(_) => Some(KnownSide::Styles(vec![KnownStyles::Rules( + expression.clone_in(self.ast.allocator()), + )])), + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => Some(KnownSide::Empty), + Expression::Identifier(identifier) if identifier.name == "undefined" => { + Some(KnownSide::Empty) + } + Expression::Identifier(_) + | Expression::StaticMemberExpression(_) + | Expression::ComputedMemberExpression(_) + | Expression::StringLiteral(_) + | Expression::TemplateLiteral(_) => { + Some(KnownSide::Class(expression.clone_in(self.ast.allocator()))) + } + _ => None, + } + } + + /// The styles of a part, each at the order the part gives it + fn part_props( + &mut self, + offset: u32, + styles: Vec>, + ) -> Vec> { + let mut props = Vec::new(); + for styles in styles { + match styles { + KnownStyles::Known(values) => { + props.extend(values.into_iter().map(ExtractStyleProp::Static)); + } + KnownStyles::Rules(mut rules) => { + self.style_values.read_in(&self.ast, &mut rules); + let ExtractResult { + mut styles, + style_order, + .. + } = extract_style_from_expression( + &self.ast, + None, + &mut rules, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + if let Some(value) = runtime_value(&styles) { + self.errors + .push((offset, runtime_value_error("css", &value))); + } + if let Some(order) = style_order { + for prop in &mut styles { + set_prop_order(prop, order); + } + } + props.extend(styles); + } + } + } + props + } pub fn new( allocator: &'a Allocator, filename: &str, @@ -335,6 +614,8 @@ impl<'a> DevupVisitor<'a> { spreads_read_once: 0, runtime_types: 0, style_values: crate::style_values::StyleValues::default(), + css_styles: None, + imported_css: FxHashMap::default(), compiled_names: FxHashSet::default(), unknown_bindings: crate::imported_constants::Unknown::default(), composes_unknown: false, @@ -557,6 +838,10 @@ impl<'a> DevupVisitor<'a> { /// `StyleX` variables and themes the program imports from other modules, /// by the name it binds them to + pub fn import_css(&mut self, styles: FxHashMap>) { + self.imported_css = styles; + } + pub fn import_stylex( &mut self, vars: FxHashMap>, @@ -1067,6 +1352,8 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { .semantic .into_scoping(), ); + self.style_values + .import(std::mem::take(&mut self.imported_css)); } walk_program(self, it); if !self.compiled_names.is_empty() { @@ -1602,7 +1889,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { )); } - if let Expression::CallExpression(call) = it { + if let Expression::CallExpression(call) = it + && self + .util_type(&call.callee) + .is_some_and(|util| matches!(util.as_ref(), UtilType::Css)) + && let Some(composed) = self.compose_known_styles(call) + { + *it = composed; + } else if let Expression::CallExpression(call) = it { if let Some(util_type) = self.util_type(&call.callee) { for argument in &mut call.arguments { let expression = match argument { @@ -1659,6 +1953,20 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if styles.is_empty() { Expression::new_string_literal(SPAN, "", None, &self.ast) } else { + let known = composed_classes.is_empty().then(|| { + let mut known = crate::composition::Composition::default(); + let mut props: Vec> = styles + .iter() + .map(|prop| prop.clone_in(self.ast.allocator())) + .collect(); + if let Some(order) = style_order { + for prop in &mut props { + set_prop_order(prop, order); + } + } + known.apply(&self.ast, props); + known.unconditional() + }); // css can not reachable let class_name = gen_class_names( &self.ast, @@ -1671,6 +1979,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.styles.extend( styles.into_iter().flat_map(ExtractStyleProp::into_extract), ); + if let Some(Some(known)) = known + && matches!(class_name, Some(Expression::StringLiteral(_))) + { + self.css_styles = Some((offset, known)); + } if let Some(cls) = class_name { cls } else { @@ -2162,6 +2475,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } .filter(|util| matches!(util.as_ref(), UtilType::Css | UtilType::Keyframes)) .and_then(|util| Some((util, self.style_values.constant(&it.id)?))); + let start = it.init.as_ref().map(|init| init.span().start); walk_variable_declarator(self, it); @@ -2169,10 +2483,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { && let Some(Expression::StringLiteral(value)) = &it.init { let value = value.value.to_string(); + let styles = self + .css_styles + .take() + .and_then(|(at, styles)| (Some(at) == start).then_some(styles)); self.style_values.insert( symbol, if matches!(util.as_ref(), UtilType::Css) { - crate::style_values::StyleValue::Class(value) + crate::style_values::StyleValue::Class(value, styles) } else { crate::style_values::StyleValue::Keyframes(value) }, From 337ddebe5dc89fbf9ac7fcea35436791c3c580f1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 22:07:51 +0900 Subject: [PATCH 02/14] test(extractor): cover known style composition branches Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/composition.rs | 4 ++-- libs/extractor/src/imported_constants.rs | 3 --- libs/extractor/src/lib.rs | 27 ++++++++++++++++++++---- libs/extractor/src/visit.rs | 15 +++++++------ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs index 7b73eafd..44dae55a 100644 --- a/libs/extractor/src/composition.rs +++ b/libs/extractor/src/composition.rs @@ -193,8 +193,8 @@ impl<'a> Composition<'a> { for (_, choice) in &self.entries { match choice { Choice::Atom(value) => values.push(value.clone()), - Choice::Empty => {} - Choice::Conditional { .. } => return None, + // A key is empty only under a condition + Choice::Empty | Choice::Conditional { .. } => return None, } } Some(values) diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 83f065a6..82ad4829 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -1084,9 +1084,6 @@ impl<'p, 'a> ModuleScope<'p, 'a> { &None, LiteralHandling::ExpandResponsiveThemeToken, ); - if crate::utils::runtime_value(&styles).is_some() { - return None; - } if let Some(order) = style_order { for prop in &mut styles { set_prop_order(prop, order); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 6ffada20..d848893a 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14703,6 +14703,14 @@ const Button = styled.button({ bg: 'red' }) "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", "`color-0-red-_a__c_hover-255 color-0-yellow--255 ${{\n\t\"0\": \"color-0-x--2\",\n\t\"1\": \"color-0-y--2\"\n}[idx] || \"\"}`" )] + #[case( + "css(yellow, { color: 'pink' } || azure)", + r#""color-0-red-_a__c_hover-255 color-0-pink--255""# + )] + #[case( + "css(yellow, undefined ?? azure)", + r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# + )] #[serial] fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { let code = readable_code(&format!( @@ -14731,9 +14739,16 @@ export const result = {call} "import {css} from '@devup-ui/core' const base = css({ color: 'red' }) export const a = css(base, { color: tone }) -export const b = css(base, { [key]: 'x', styleOrder: 2 })", +export const b = css(base, { [key]: 'x', styleOrder: 2 }) +export const c = css(base, getStyles()) +export const d = css(base, { positioning: side, styleOrder: 2 })", ); + assert!(!code.contains("test.tsx:6:"), "{code}"); assert!(code.contains("`css()` cannot use `tone`"), "{code}"); + assert!( + code.contains("Cannot compose `\"color-0-red--255\", getStyles()`"), + "{code}" + ); } // The styles of a `css()` class another module exports compose as well @@ -14754,17 +14769,20 @@ export const text = css`color: blue;`; export const runtime = css({ color: globalThis.tone }); export const listed = css([{ color: 'b' }]); export const spaced = Devup.css({ color: 'c' }); -export const fade = keyframes({ from: { opacity: 0 } });", +export const fade = keyframes({ from: { opacity: 0 } }); +export const called = Devup({ color: 'e' }); +export const ordered = css({ color: 'f', m: 2, styleOrder: 2 });", )]; let resolver = memory_resolver(modules); css::debug::set_debug(true); let output = extract_with_modules( "/src/App.tsx", "import { css } from '@devup-ui/react'; -import { base, danger, twice, text, runtime, listed, spaced, fade } from './styles'; +import { base, danger, twice, text, runtime, listed, spaced, fade, called, ordered } from './styles'; export const a = css(base, danger); export const b = css(danger, base, { m: 1 }); -export const c = css(twice, text, runtime, listed, spaced, fade);", +export const c = css(twice, text, runtime, listed, spaced, fade); +export const d = css(ordered, danger, called);", ExtractOption { import_aliases: HashMap::from([( "@emotion/css".to_string(), @@ -14781,6 +14799,7 @@ export const c = css(twice, text, runtime, listed, spaced, fade);", r#"export const a = "color-0-red-_a__c_hover-255-a color-0-crimson--255-a";"#, r#"export const b = "color-0-red-_a__c_hover-255-a color-0-teal--255-a margin-0-4px--255-a";"#, "export const c = `${twice} ${text} ${runtime} ${listed} ${spaced} ${fade}`;", + "export const d = `margin-0-8px--2-a color-0-crimson--255-a ${called}`;", ] { assert!( output.code.contains(expected), diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f41db587..19512b61 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -414,16 +414,19 @@ impl<'a> DevupVisitor<'a> { parts.push(KnownPart::Styles(side)); Some(()) } - KnownSide::Empty - if logical.operator == LogicalOperator::Coalesce + KnownSide::Empty => { + // `false ?? right` is `false`, which composes nothing + let left_stays = logical.operator == LogicalOperator::Coalesce && matches!( unwrap_syntax_only(&logical.left), Expression::BooleanLiteral(_) - ) => - { - Some(()) + ); + if left_stays { + Some(()) + } else { + self.known_parts(&logical.right, parts) + } } - KnownSide::Empty => self.known_parts(&logical.right, parts), KnownSide::Class(left) => { let test = if logical.operator == LogicalOperator::Or { clone(&left) From 024a24c500d8c5138e397fff8cef99b9ed9c2f35 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 22:27:59 +0900 Subject: [PATCH 03/14] chore: satisfy clippy assert_is_empty lint on Rust 1.99 Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- bindings/devup-ui-wasm/src/lib.rs | 8 ++++---- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 8 ++++---- libs/extractor/src/tailwind.rs | 4 ++-- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f..0a2347ac 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb02..3b2346ae 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index d848893a..9d6d565e 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -838,8 +838,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -13600,7 +13600,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -19034,7 +19034,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f517..70643aa7 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] From 71585a9bd12f88f74556dcc49e4b6a79eab93437 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 22:42:22 +0900 Subject: [PATCH 04/14] test(extractor): cover false ?? composition branch Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 9d6d565e..6f6e446e 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14711,6 +14711,10 @@ const Button = styled.button({ bg: 'red' }) "css(yellow, undefined ?? azure)", r#""color-0-red-_a__c_hover-255 background-0-black--255 color-0-azure--255""# )] + #[case( + "css(yellow, false ?? azure)", + r#""color-0-red-_a__c_hover-255 color-0-yellow--255""# + )] #[serial] fn test_css_composes_known_classes(#[case] call: &str, #[case] expected: &str) { let code = readable_code(&format!( From 52f45180efad47f4471164288482d4335dfa094d Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Thu, 1 Oct 2026 22:58:16 +0900 Subject: [PATCH 05/14] refactor(extractor): split coalesce check so composition lines are measurable Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/visit.rs | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 19512b61..a6aaee53 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -71,6 +71,15 @@ use rustc_hash::{FxHashMap, FxHashSet}; use std::borrow::Cow; use std::rc::Rc; +/// `false ?? right` is `false`, which composes nothing +fn coalesce_keeps_left(logical: &oxc_ast::ast::LogicalExpression<'_>) -> bool { + logical.operator == LogicalOperator::Coalesce + && matches!( + unwrap_syntax_only(&logical.left), + Expression::BooleanLiteral(_) + ) +} + fn property_stays(property: &ObjectProperty<'_>) -> bool { property .key @@ -414,19 +423,8 @@ impl<'a> DevupVisitor<'a> { parts.push(KnownPart::Styles(side)); Some(()) } - KnownSide::Empty => { - // `false ?? right` is `false`, which composes nothing - let left_stays = logical.operator == LogicalOperator::Coalesce - && matches!( - unwrap_syntax_only(&logical.left), - Expression::BooleanLiteral(_) - ); - if left_stays { - Some(()) - } else { - self.known_parts(&logical.right, parts) - } - } + KnownSide::Empty if coalesce_keeps_left(logical) => Some(()), + KnownSide::Empty => self.known_parts(&logical.right, parts), KnownSide::Class(left) => { let test = if logical.operator == LogicalOperator::Or { clone(&left) From 1cedf9f8746e4fc1eae7493fd62397daecca41f9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 00:12:31 +0900 Subject: [PATCH 06/14] fix(extractor): compose styled extensions, merge attrs and let later JSX spreads win Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_compose_styled.json | 7 + .../extractor/extract_style_from_styled.rs | 468 +++++++++++++++--- libs/extractor/src/lib.rs | 88 +++- libs/extractor/src/prop_modify_utils.rs | 293 ++++++----- ...extractor__tests__dynamic_as_elements.snap | 2 +- ...or__tests__evaluate_build_time_values.snap | 2 +- ..._every_runtime_value_an_element_holds.snap | 2 +- .../extractor__tests__extract_selector-2.snap | 4 +- ...extract_style_props_with_class_name-9.snap | 4 +- ...tor__tests__inline_imported_constants.snap | 2 +- ...ctor__tests__read_once_in_every_place.snap | 2 +- .../extractor__tests__rest_props-2.snap | 4 +- .../extractor__tests__rest_props.snap | 4 +- .../extractor__tests__spread_props.snap | 4 +- ...tractor__tests__spreads_are_read_once.snap | 2 +- .../snapshots/extractor__tests__styled-7.snap | 4 +- .../snapshots/extractor__tests__styled-8.snap | 4 +- ...ponents_attrs_and_every_styled_import.snap | 2 +- ...__tests__styles_the_build_cannot_read.snap | 2 +- ...actor__tests__support_transpile_mjs-5.snap | 4 +- libs/extractor/src/style_values.rs | 11 + libs/extractor/src/visit.rs | 42 +- .../__snapshots__/index.browser.test.tsx.snap | 18 +- .../__snapshots__/index.browser.test.tsx.snap | 2 +- .../__snapshots__/index.browser.test.tsx.snap | 24 +- .../__snapshots__/index.browser.test.tsx.snap | 2 +- 26 files changed, 755 insertions(+), 248 deletions(-) create mode 100644 .changepacks/changepack_log_compose_styled.json diff --git a/.changepacks/changepack_log_compose_styled.json b/.changepacks/changepack_log_compose_styled.json new file mode 100644 index 00000000..4d05b283 --- /dev/null +++ b/.changepacks/changepack_log_compose_styled.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed024..7091a2a6 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -46,7 +46,130 @@ struct Base<'a> { bound: Option>, } +/// A styled component the file defines, which a component extending it +/// renders in its place: what it renders, its classes and styles, and its +/// attrs, as the extension applies them before its own +pub struct StyledDefinition<'a> { + name: String, + bound: Option>, + classes: Vec>, + styles: Vec>, + attrs: Vec>, +} + +impl StyledDefinition<'_> { + /// Whether an extension can render what this definition renders: a value + /// only the runtime gives is read again only when it is a binding + #[must_use] + pub fn extendable(&self) -> bool { + self.bound + .as_ref() + .is_none_or(|bound| matches!(bound, Expression::Identifier(_))) + } +} + +/// What extracting a styled component gives +pub struct StyledExtraction<'a> { + pub result: ExtractResult<'a>, + pub expression: Expression<'a>, + pub errors: Vec<(u32, String)>, + pub definition: Option>, +} + +/// The styles a component renders: those it extends or its base component +/// brings, then its own, a later declaration replacing an earlier one +fn compose_styles<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + defaults: Option>, + own: Vec>, +) -> Vec> { + let mut earlier: Vec> = inherited.map_or_else(Vec::new, |inherited| { + inherited + .styles + .iter() + .map(|style| style.clone_in(ast_builder.allocator())) + .collect() + }); + earlier.extend(defaults.into_iter().flatten().map(ExtractStyleProp::Static)); + if earlier.is_empty() { + return own; + } + let mut composition = crate::composition::Composition::default(); + composition.apply(ast_builder, earlier); + composition.apply(ast_builder, own); + composition.into_props() +} + +/// The classes and attrs a component extending `inherited` starts from +fn inherited_parts<'a>( + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + attrs: &[Expression<'a>], +) -> (Vec>, Vec>) { + let allocator = ast_builder.allocator(); + let (classes, mut all_attrs) = inherited.map_or_else( + || (Vec::new(), Vec::new()), + |inherited| { + ( + inherited + .classes + .iter() + .map(|c| c.clone_in(allocator)) + .collect(), + inherited + .attrs + .iter() + .map(|a| a.clone_in(allocator)) + .collect(), + ) + }, + ); + all_attrs.extend(attrs.iter().map(|attr| attr.clone_in(allocator))); + (classes, all_attrs) +} + impl<'a> Base<'a> { + /// What `inherited` renders, in place of the component defining it + fn extending( + self, + ast_builder: &AstBuilder<'a>, + inherited: Option<&StyledDefinition<'a>>, + ) -> Self { + match inherited { + Some(inherited) => Self { + name: inherited.name.clone(), + styles: None, + bound: inherited + .bound + .as_ref() + .map(|bound| bound.clone_in(ast_builder.allocator())), + }, + None => self, + } + } + + /// The definition of the component rendering this base + fn definition( + &self, + ast_builder: &AstBuilder<'a>, + classes: Vec>, + styles: &[ExtractStyleProp<'a>], + attrs: &[Expression<'a>], + ) -> StyledDefinition<'a> { + let allocator = ast_builder.allocator(); + StyledDefinition { + name: self.name.clone(), + bound: self.bound.as_ref().map(|bound| bound.clone_in(allocator)), + classes, + styles: styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), + } + } + const fn named(name: String) -> Self { Self { name, @@ -65,6 +188,29 @@ impl<'a> Base<'a> { } } +/// The component `styled(Component)` extends, as written: the argument naming +/// it in a tagged template `styled(Component)`, `styled(Component)({...})` or +/// `styled(Component, {...})` +#[must_use] +pub fn extended<'b, 'a>(expression: &'b Expression<'a>) -> Option<&'b Expression<'a>> { + let factory = match expression { + Expression::TaggedTemplateExpression(tag) => &tag.tag, + Expression::CallExpression(call) + if matches!( + unwrap_syntax_only(&call.callee), + Expression::CallExpression(_) + ) => + { + &call.callee + } + expression => expression, + }; + let Expression::CallExpression(call) = unwrap_syntax_only(factory) else { + return None; + }; + unwrap_syntax_only(call.arguments.first()?.as_expression()?).into() +} + fn extract_base_tag_and_class_name<'a>( ast_builder: &AstBuilder<'a>, input: &Expression<'a>, @@ -183,7 +329,8 @@ pub fn extract_style_from_styled<'a>( split_filename: Option<&str>, imports: &FxHashMap, attrs: &[Expression<'a>], -) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { + inherited: Option<&StyledDefinition<'a>>, +) -> StyledExtraction<'a> { let mut composed_classes = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression @@ -209,7 +356,8 @@ pub fn extract_style_from_styled<'a>( None => {} } } - let (result, new_expr) = if let Expression::TaggedTemplateExpression(tag) = expression + let (result, new_expr, definition) = if let Expression::TaggedTemplateExpression(tag) = + expression && let Some(mut base) = extract_base_tag_and_class_name(ast_builder, &tag.tag, imports) { // Case 1: styled.div`css` or styled("div")`css` @@ -225,16 +373,16 @@ pub fn extract_style_from_styled<'a>( let expression = &tag.quasi.expressions[index]; errors.push((expression.span().start, unplaced_error(expression))); } - let mut props_styles: Vec> = styles + let own: Vec> = styles .into_iter() .map(|ex| ExtractStyleProp::Static(ex.into())) .collect(); + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut props_styles = compose_styles(ast_builder, inherited, defaults, own); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); - if let Some(default_class_name) = base.styles.take() { - props_styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); - } - - let mixins = statements.into_iter().map(|index| { + classes.extend(statements.into_iter().map(|index| { let mixin = &tag.quasi.expressions[index]; if matches!( unwrap_syntax_only(mixin), @@ -255,18 +403,15 @@ pub fn extract_style_from_styled<'a>( } else { mixin.clone_in(ast_builder.allocator()) } - }); + })); let class_name = merge_expression_for_class_name( ast_builder, - mixins - .collect::>() - .into_iter() - .chain(gen_class_names( - ast_builder, - &mut props_styles, - None, - split_filename, - )), + clone_all(ast_builder, &classes).chain(gen_class_names( + ast_builder, + &mut props_styles, + None, + split_filename, + )), ); let tag = Some(Expression::new_string_literal( SPAN, @@ -280,7 +425,9 @@ pub fn extract_style_from_styled<'a>( &class_name, &gen_styles(ast_builder, &props_styles, None), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let definition = base.definition(ast_builder, classes, &props_styles, &attrs); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles: props_styles, @@ -290,7 +437,7 @@ pub fn extract_style_from_styled<'a>( props: None, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else if let Expression::CallExpression(call) = expression && let Some((mut base, style_index)) = resolve_styled_call_target(ast_builder, call, imports) @@ -324,16 +471,23 @@ pub fn extract_style_from_styled<'a>( .into_iter() .map(|(offset, code)| (offset, build_time_error("styled", &code, STYLE_OBJECT))), ); - if let Some(default_class_name) = base.styles.take() { - styles.extend(default_class_name.into_iter().map(ExtractStyleProp::Static)); + if let Some(order) = style_order { + for style in &mut styles { + crate::composition::set_prop_order(style, order); + } } + let defaults = base.styles.take(); + let base = base.extending(ast_builder, inherited); + let mut styles = compose_styles(ast_builder, inherited, defaults, styles); + let (mut classes, attrs) = inherited_parts(ast_builder, inherited, attrs); + classes.extend(composed_classes); let class_name = merge_expression_for_class_name( ast_builder, - composed_classes.into_iter().chain(gen_class_names( + clone_all(ast_builder, &classes).chain(gen_class_names( ast_builder, &mut styles, - style_order, + None, split_filename, )), ); @@ -343,7 +497,9 @@ pub fn extract_style_from_styled<'a>( &class_name, &gen_styles(ast_builder, &styles, None), ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let definition = base.definition(ast_builder, classes, &styles, &attrs); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles, @@ -353,7 +509,7 @@ pub fn extract_style_from_styled<'a>( props, }; - (Some(result), Some(styled_component)) + (Some(result), Some(styled_component), Some(definition)) } else { // Left as written it would call `styled` at runtime, which only the build runs let code = match &*expression { @@ -364,13 +520,23 @@ pub fn extract_style_from_styled<'a>( expression.span().start, build_time_error("styled", &code, STYLED_FACTORY), )); - (None, None) + (None, None, None) }; - ( - result.unwrap_or_else(ExtractResult::default), - new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), + StyledExtraction { + result: result.unwrap_or_else(ExtractResult::default), + expression: new_expr.unwrap_or_else(|| expression.clone_in(ast_builder.allocator())), errors, - ) + definition, + } +} + +fn clone_all<'s, 'a>( + ast_builder: &'s AstBuilder<'a>, + expressions: &'s [Expression<'a>], +) -> impl Iterator> + 's { + expressions + .iter() + .map(|expression| expression.clone_in(ast_builder.allocator())) } /// The name the attrs wrapper binds props to, chosen not to shadow what the @@ -452,44 +618,215 @@ fn apply_attrs<'a>( if attrs.is_empty() { return component; } - let props = || Expression::new_identifier(SPAN, ATTRS_PROPS, ast_builder); - let mut merged = props(); + let merged = if attrs.iter().all(sets_plain_props) { + spread_attrs(ast_builder, attrs) + } else { + merge_attrs(ast_builder, attrs) + }; + props_arrow( + ast_builder, + wrap_direct_call(ast_builder, &component, &[merged]), + ) +} + +/// The binding each attrs step reads the props merged so far through +const ATTRS_CONTEXT: &str = "__devupContext"; +/// The binding each attrs step reads what its attrs give through +const ATTRS_VALUE: &str = "__devupAttrs"; + +/// Whether `attr` is an object setting props other than `className` and +/// `style` by name, which spreading over the props merges as attrs do +fn sets_plain_props(attr: &Expression<'_>) -> bool { + let Expression::ObjectExpression(object) = unwrap_syntax_only(attr) else { + return false; + }; + object.properties.iter().all(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed + && property + .key + .static_name() + .is_some_and(|name| name != "className" && name != "style")) + }) +} + +/// The props with each attrs spread over them in order +fn spread_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = identifier(ast_builder, ATTRS_PROPS); + for attr in attrs { + merged = spread_objects(ast_builder, merged, attr.clone_in(ast_builder.allocator())); + } + merged +} + +/// The props merged as styled-components merges attrs: each attrs, or what +/// calling it with the props merged so far gives, joins its `className` to +/// theirs, merges its `style` over theirs and replaces their other props; the +/// caller's `className` comes last +fn merge_attrs<'a>(ast_builder: &AstBuilder<'a>, attrs: &[Expression<'a>]) -> Expression<'a> { + let mut merged = with_property( + ast_builder, + identifier(ast_builder, ATTRS_PROPS), + "className", + identifier(ast_builder, "undefined"), + ); for attr in attrs { - let attr = attr.clone_in(ast_builder.allocator()); - if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { - merged = spread_objects(ast_builder, merged, attr); - continue; - } - let called = wrap_direct_call(ast_builder, &attr, &[props()]); - let resolved = if matches!( - unwrap_syntax_only(&attr), - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) - ) { - called - } else { - let is_function = Expression::new_binary_expression( + let resolved = spread_objects( + ast_builder, + Expression::new_object_expression( SPAN, - Expression::new_unary_expression( - SPAN, - UnaryOperator::Typeof, - attr.clone_in(ast_builder.allocator()), - ast_builder, - ), - BinaryOperator::StrictEquality, - Expression::new_string_literal(SPAN, "function", None, ast_builder), + oxc_allocator::Vec::new_in(ast_builder), ast_builder, - ); - Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) - }; - let step = props_arrow(ast_builder, spread_objects(ast_builder, props(), resolved)); + ), + resolve_attrs(ast_builder, attr.clone_in(ast_builder.allocator())), + ); + let merge = call_with_values( + ast_builder, + vec![(ATTRS_VALUE.to_string(), resolved)], + merge_attrs_value(ast_builder), + ); + let step = named_arrow(ast_builder, ATTRS_CONTEXT, merge); merged = wrap_direct_call(ast_builder, &step, &[merged]); } - props_arrow( + let caller_class = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_PROPS, "className"), + ); + let last = named_arrow( + ast_builder, + ATTRS_CONTEXT, + with_property( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + "className", + caller_class, + ), + ); + wrap_direct_call(ast_builder, &last, &[merged]) +} + +/// What `attr` gives: itself when it is an object, or what calling it with +/// the props merged so far gives when it is a function +fn resolve_attrs<'a>(ast_builder: &AstBuilder<'a>, attr: Expression<'a>) -> Expression<'a> { + if matches!(unwrap_syntax_only(&attr), Expression::ObjectExpression(_)) { + return attr; + } + let called = wrap_direct_call( + ast_builder, + &attr, + &[identifier(ast_builder, ATTRS_CONTEXT)], + ); + if matches!( + unwrap_syntax_only(&attr), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + return called; + } + let is_function = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression( + SPAN, + UnaryOperator::Typeof, + attr.clone_in(ast_builder.allocator()), + ast_builder, + ), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "function", None, ast_builder), + ast_builder, + ); + Expression::new_conditional_expression(SPAN, is_function, called, attr, ast_builder) +} + +/// `{ ...context, ...attrs, className, style }` with the classes joined and +/// the styles merged +fn merge_attrs_value<'a>(ast_builder: &AstBuilder<'a>) -> Expression<'a> { + let merged = spread_objects( + ast_builder, + identifier(ast_builder, ATTRS_CONTEXT), + identifier(ast_builder, ATTRS_VALUE), + ); + let class_name = joined_class( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "className"), + member(ast_builder, ATTRS_VALUE, "className"), + ); + let style = spread_objects( + ast_builder, + member(ast_builder, ATTRS_CONTEXT, "style"), + member(ast_builder, ATTRS_VALUE, "style"), + ); + let merged = with_property(ast_builder, merged, "className", class_name); + with_property(ast_builder, merged, "style", style) +} + +fn identifier<'a>(ast_builder: &AstBuilder<'a>, name: &'static str) -> Expression<'a> { + Expression::new_identifier(SPAN, name, ast_builder) +} + +fn member<'a>( + ast_builder: &AstBuilder<'a>, + object: &'static str, + name: &'static str, +) -> Expression<'a> { + Expression::StaticMemberExpression(oxc_ast::ast::StaticMemberExpression::boxed( + SPAN, + identifier(ast_builder, object), + oxc_ast::ast::IdentifierName::new(SPAN, name, ast_builder), + false, + ast_builder, + )) +} + +/// `[first, second].filter(Boolean).join(" ") || undefined` +fn joined_class<'a>( + ast_builder: &AstBuilder<'a>, + first: Expression<'a>, + second: Expression<'a>, +) -> Expression<'a> { + let joined = wrap_array_filter(ast_builder, &[first, second]) + .unwrap_or_else(|| identifier(ast_builder, "undefined")); + Expression::new_logical_expression( + SPAN, + joined, + LogicalOperator::Or, + identifier(ast_builder, "undefined"), ast_builder, - wrap_direct_call(ast_builder, &component, &[merged]), ) } +/// `object` with `name` set to `value`: an object spreading it, then setting +/// the property +fn with_property<'a>( + ast_builder: &AstBuilder<'a>, + object: Expression<'a>, + name: &'static str, + value: Expression<'a>, +) -> Expression<'a> { + let mut properties = match object { + Expression::ObjectExpression(object) => object.unbox().properties, + object => oxc_allocator::Vec::from_array_in( + [ObjectPropertyKind::new_spread_property( + SPAN, + object, + ast_builder, + )], + ast_builder, + ), + }; + properties.push(ObjectPropertyKind::new_object_property( + SPAN, + oxc_ast::ast::PropertyKind::Init, + PropertyKey::new_static_identifier(SPAN, name, ast_builder), + value, + false, + false, + false, + ast_builder, + )); + Expression::new_object_expression(SPAN, properties, ast_builder) +} + fn spread_objects<'a>( ast_builder: &AstBuilder<'a>, first: Expression<'a>, @@ -510,10 +847,19 @@ fn spread_objects<'a>( } fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expression<'a> { + named_arrow(ast_builder, ATTRS_PROPS, body) +} + +/// `(name) => body` +fn named_arrow<'a>( + ast_builder: &AstBuilder<'a>, + name: &'static str, + body: Expression<'a>, +) -> Expression<'a> { let parameter = FormalParameter::new( SPAN, oxc_allocator::Vec::new_in(ast_builder), - BindingPattern::new_binding_identifier(SPAN, ATTRS_PROPS, ast_builder), + BindingPattern::new_binding_identifier(SPAN, name, ast_builder), None::>>, None::>>, false, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 6f6e446e..72d5de7d 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -14738,21 +14738,85 @@ export const result = {call} #[test] #[serial] - fn test_css_composing_reports_runtime_values() { + fn test_styled_extension_composes_base() { let code = readable_code( - "import {css} from '@devup-ui/core' -const base = css({ color: 'red' }) -export const a = css(base, { color: tone }) -export const b = css(base, { [key]: 'x', styleOrder: 2 }) -export const c = css(base, getStyles()) -export const d = css(base, { positioning: side, styleOrder: 2 })", + "import {styled} from '@devup-ui/core' +const Base = styled.button`color: red; background: white; &:hover { color: red; }` +const Ext = styled(Base)`color: blue; &:hover { color: blue; }` +const Obj = styled.div({ color: 'red', p: 2 }) +const ObjExt = styled(Obj)({ color: 'blue' }) +const Twice = styled(ObjExt, { m: 1 }) +const Runtime = styled(make())({ color: 'red' }) +const FromRuntime = styled(Runtime)({ color: 'blue' }) +let Changing = styled.div({ color: 'red' }) +const FromChanging = styled(Changing)({ color: 'blue' })", ); - assert!(!code.contains("test.tsx:6:"), "{code}"); - assert!(code.contains("`css()` cannot use `tone`"), "{code}"); - assert!( - code.contains("Cannot compose `\"color-0-red--255\", getStyles()`"), - "{code}" + for expected in [ + "const Ext = ({ style, className, ...rest }) => -
-
+
+
Option 1
-
+
Option 2
-
+
-
+
Option 3
-
+
Option 4
-
-