diff --git a/.changepacks/changepack_log_compose_known_styles.json b/.changepacks/changepack_log_compose_known_styles.json new file mode 100644 index 000000000..6aa4c50d9 --- /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/.changepacks/changepack_log_compose_styled.json b/.changepacks/changepack_log_compose_styled.json new file mode 100644 index 000000000..4d05b2838 --- /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/.changepacks/changepack_log_compose_stylex.json b/.changepacks/changepack_log_compose_stylex.json new file mode 100644 index 000000000..d6e8db0d1 --- /dev/null +++ b/.changepacks/changepack_log_compose_stylex.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "stylex.props() and stylex.attrs() merge namespaces this file creates key by key as StyleX does: a later namespace's key replaces an earlier one's, so props(red, blue) is blue and props(blue, red) is red, and a key set to null removes the earlier value; conditions (cond && styles.x, cond ? a : b) choose per key. Arguments the build cannot read key by key (namespaces from other modules, props, dynamic namespaces, include()) keep the previous joined behavior", + "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 000000000..44dae55a6 --- /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()), + // A key is empty only under a condition + 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/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index f09b03b07..74cf36ea2 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -137,6 +137,10 @@ impl ExtractDynamicStyle { self.identifier.as_str() } + pub(crate) const fn identifier_mut(&mut self) -> &mut String { + &mut self.identifier + } + pub const fn style_order(&self) -> Option { self.style_order } diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index a5c7f5ab0..246127399 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -10,7 +10,7 @@ use crate::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, styled_record::prepare, - styled_record_evaluation::bind_render, + styled_record_evaluation::{Evaluations, bind_render, visit_style_expressions}, }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, @@ -30,6 +30,7 @@ use oxc_ast::{ }, builder::AstBuilder, }; +use oxc_ast_visit::VisitMut; use oxc_span::{GetSpan, SPAN}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; @@ -50,7 +51,257 @@ 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, + target: Option>, + bound: Option>, + classes: Vec>, + styles: Vec>, + attrs: Vec>, + bindings: Vec<(String, Expression<'a>)>, +} + +struct Rename<'b, 'a> { + builder: &'b AstBuilder<'a>, + names: FxHashMap, +} + +impl<'a> VisitMut<'a> for Rename<'_, 'a> { + fn visit_identifier_reference( + &mut self, + identifier: &mut oxc_ast::ast::IdentifierReference<'a>, + ) { + let mut code = String::new(); + // Dynamic identifiers can hold emitted JS code. Captured names + // were excluded from authored source, so only whole tokens change. + let separator = |ch: char| !ch.is_alphanumeric() && ch != '_' && ch != '$'; + for chunk in identifier.name.split_inclusive(separator) { + let token = chunk.trim_end_matches(separator); + code.push_str(self.names.get(token).map_or(token, String::as_str)); + code.push_str(&chunk[token.len()..]); + } + identifier.name = oxc_ast::ast::Ident::from_in(code.as_str(), self.builder.allocator()); + } +} + +impl<'a> StyledDefinition<'a> { + fn visit_expressions( + &mut self, + builder: &AstBuilder<'a>, + mut visit: impl FnMut(&mut Expression<'a>), + ) { + for expression in self + .target + .iter_mut() + .chain(&mut self.bound) + .chain(&mut self.classes) + .chain(&mut self.attrs) + { + visit(expression); + } + for (_, value) in &mut self.bindings { + visit(value); + } + for style in &mut self.styles { + visit_style_expressions(builder, style, &mut visit); + } + } + + fn rebind(&self, builder: &AstBuilder<'a>, scope: &mut Expression<'a>) -> Self { + let allocator = builder.allocator(); + let mut inherited = Self { + name: self.name.clone(), + target: self.target.as_ref().map(|value| value.clone_in(allocator)), + bound: self.bound.as_ref().map(|value| value.clone_in(allocator)), + classes: clone_all(builder, &self.classes).collect(), + styles: self + .styles + .iter() + .map(|style| style.clone_in(allocator)) + .collect(), + attrs: clone_all(builder, &self.attrs).collect(), + bindings: self + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + }; + let mut reserved = vec![scope.clone_in(allocator)]; + inherited.visit_expressions(builder, |value| reserved.push(value.clone_in(allocator))); + reserved.extend(inherited.bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), allocator), + builder, + ) + })); + let reservation = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder), + builder, + ); + let mut reads = Evaluations::new(builder, readable_code(&reservation)); + let mut rename = Rename { + builder, + names: FxHashMap::default(), + }; + for (name, _) in &mut inherited.bindings { + let fresh = reads.fresh_name(); + rename.names.insert(name.clone(), fresh.clone()); + *name = fresh; + } + for (_, value) in &mut inherited.bindings { + rename.visit_expression(value); + } + for style in &mut inherited.styles { + visit_style_expressions(builder, style, &mut |value| rename.visit_expression(value)); + } + let mut reserved = vec![reservation]; + reserved.extend(inherited.bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), allocator), + builder, + ) + })); + *scope = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(reserved.into_iter().map(Into::into), builder), + builder, + ); + inherited + } +} + +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(), + target: inherited + .target + .as_ref() + .map(|target| target.clone_in(ast_builder.allocator())), + 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>], + bindings: &[(String, Expression<'a>)], + ) -> StyledDefinition<'a> { + let allocator = ast_builder.allocator(); + StyledDefinition { + name: self.name.clone(), + target: self + .target + .as_ref() + .map(|target| target.clone_in(allocator)), + 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(), + bindings: bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(allocator))) + .collect(), + } + } + const fn named(name: String) -> Self { Self { name, @@ -70,6 +321,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>, @@ -192,9 +466,12 @@ pub fn extract_style_from_styled<'a>( split_filename: Option<&str>, imports: &FxHashMap, attrs: &[Expression<'a>], + inherited: Option<&StyledDefinition<'a>>, helper: &mut Option, -) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { - let original_scope = expression.clone_in(ast_builder.allocator()); +) -> StyledExtraction<'a> { + let mut original_scope = expression.clone_in(ast_builder.allocator()); + let inherited = inherited.map(|definition| definition.rebind(ast_builder, &mut original_scope)); + let inherited = inherited.as_ref(); let mut composed_classes = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression @@ -220,7 +497,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` @@ -243,16 +521,16 @@ pub fn extract_style_from_styled<'a>( .filter(|expression| crate::css_utils::reads_unmapped_theme(expression)) .map(|expression| crate::css_utils::unmapped_theme_error("styled", 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), @@ -273,18 +551,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, @@ -292,17 +567,26 @@ pub fn extract_style_from_styled<'a>( None, ast_builder, )); + let bindings = inherited.map_or_else(Vec::new, |definition| { + definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) + .collect() + }); + let definition = base.definition(ast_builder, classes, &props_styles, &attrs, &bindings); let component = create_styled_component( ast_builder, &base, StyledRender { class_name: &class_name, style_vars: &gen_styles(ast_builder, &props_styles, None), - bindings: Vec::new(), + bindings, }, helper, ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles: props_styles, @@ -312,7 +596,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) @@ -323,13 +607,20 @@ pub fn extract_style_from_styled<'a>( let prepared = call.arguments[style_index] .as_expression() .and_then(|rules| prepare(ast_builder, rules, &original_scope)); - let bindings = match prepared { + let mut bindings: Vec<_> = inherited.map_or_else(Vec::new, |definition| { + definition + .bindings + .iter() + .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) + .collect() + }); + bindings.extend(match prepared { Some(prepared) => { call.arguments[style_index] = Argument::from(prepared.rules); prepared.bindings } None => Vec::new(), - }; + }); // Extract styles from object expression let ExtractResult { mut styles, @@ -357,19 +648,27 @@ pub fn extract_style_from_styled<'a>( build_time_error("styled", &code, requirement.unwrap_or(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, )), ); + let definition = base.definition(ast_builder, classes, &styles, &attrs, &bindings); let component = create_styled_component( ast_builder, &base, @@ -380,7 +679,8 @@ pub fn extract_style_from_styled<'a>( }, helper, ); - let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); + let styled_component = + base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); let result = ExtractResult { styles, @@ -390,7 +690,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 { @@ -401,13 +701,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 @@ -489,44 +799,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 { - 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( + 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 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>, @@ -547,10 +1028,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/extractor/extract_style_from_stylex.rs b/libs/extractor/src/extractor/extract_style_from_stylex.rs index 69ff713a8..5010361b4 100644 --- a/libs/extractor/src/extractor/extract_style_from_stylex.rs +++ b/libs/extractor/src/extractor/extract_style_from_stylex.rs @@ -119,7 +119,9 @@ type Leaf<'l> = dyn Fn(&str, &Expression<'_>) -> Option + 'l; /// Handles static string/number values (Phase 1) and value-level conditions (Phase 2); /// what cannot be read at build time is reported in `errors`. /// -/// Returns a Vec of `(namespace_name, style_props, css_vars, include_refs)` tuples. Each namespace +/// Returns a Vec of `(namespace_name, style_props, css_vars, include_refs, key_groups)` tuples, +/// `key_groups` giving each top-level key of a static namespace and how many of its +/// styles it gives. Each namespace /// corresponds to a top-level key in the `stylex.create({...})` argument. #[allow(clippy::type_complexity)] pub fn extract_stylex_namespace_styles<'a>( @@ -132,6 +134,7 @@ pub fn extract_stylex_namespace_styles<'a>( Vec>, Option>, Vec, + Vec<(String, usize)>, )> { // A keyframes name or a `defineVars` member reads as the value it stands for let leaf = |property: &str, value: &Expression<'_>| { @@ -173,13 +176,14 @@ pub fn extract_stylex_namespace_styles<'a>( )); continue; }; - result.push((ns_name, styles, Some(css_vars), vec![])); + result.push((ns_name, styles, Some(css_vars), vec![], vec![])); } Expression::ObjectExpression(ns_obj) => { - let (styles, include_refs) = extract_stylex_namespace(ns_obj, &leaf, errors); - result.push((ns_name, styles, None, include_refs)); + let (styles, include_refs, groups) = + extract_stylex_namespace(ns_obj, &leaf, errors); + result.push((ns_name, styles, None, include_refs, groups)); } - Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![])), + Expression::NullLiteral(_) => result.push((ns_name, vec![], None, vec![], vec![])), value => errors.push(( value.span().start, build_time_error( @@ -193,14 +197,21 @@ pub fn extract_stylex_namespace_styles<'a>( result } -/// The styles and `include()` references of one static namespace +/// The styles and `include()` references of one static namespace, with each +/// top-level key and how many of the styles it gives +#[allow(clippy::type_complexity)] fn extract_stylex_namespace<'a>( namespace: &ObjectExpression<'_>, leaf: &Leaf<'_>, errors: &mut Vec<(u32, String)>, -) -> (Vec>, Vec) { +) -> ( + Vec>, + Vec, + Vec<(String, usize)>, +) { let mut styles = vec![]; let mut include_refs = vec![]; + let mut groups = vec![]; for style_prop in &namespace.properties { let style_prop = match style_prop { ObjectPropertyKind::ObjectProperty(style_prop) => style_prop, @@ -232,6 +243,7 @@ fn extract_stylex_namespace<'a>( continue; }; let parent_selectors = [SelectorPart::Pseudo(prop_name.to_string())]; + let before = styles.len(); for inner_prop in &inner_obj.properties { let inner_prop = match inner_prop { ObjectPropertyKind::ObjectProperty(inner_prop) => inner_prop, @@ -255,6 +267,7 @@ fn extract_stylex_namespace<'a>( ), ); } + groups.push((prop_name.to_string(), styles.len() - before)); continue; } @@ -264,12 +277,14 @@ fn extract_stylex_namespace<'a>( "[stylex] WARNING: Shorthand property '{css_property}' may cause unexpected specificity issues. Consider using longhand properties (e.g., 'marginTop', 'paddingLeft')." ); } + let before = styles.len(); push_decomposed( &mut styles, decompose_value_conditions(&css_property, &style_prop.value, &[], leaf, errors), ); + groups.push((prop_name.to_string(), styles.len() - before)); } - (styles, include_refs) + (styles, include_refs, groups) } fn push_decomposed(styles: &mut Vec>, decomposed: Vec) { diff --git a/libs/extractor/src/extractor/styled_record_evaluation.rs b/libs/extractor/src/extractor/styled_record_evaluation.rs index 6a2ac15c8..662a220ab 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation.rs @@ -23,13 +23,7 @@ impl<'b, 'a> Evaluations<'b, 'a> { } pub fn capture(&mut self, value: &Expression<'a>, guard: &Expression<'a>) -> Expression<'a> { - let name = loop { - let name = format!("__devupRecord{}", self.next); - self.next += 1; - if !self.source.contains(&name) { - break name; - } - }; + let name = self.fresh_name(); let value = self.choose(guard, value, &self.undefined()); self.bindings.push((name.clone(), value)); Expression::new_identifier( @@ -39,6 +33,16 @@ impl<'b, 'a> Evaluations<'b, 'a> { ) } + pub fn fresh_name(&mut self) -> String { + loop { + let name = format!("__devupRecord{}", self.next); + self.next += 1; + if !self.source.contains(&name) { + return name; + } + } + } + pub fn boolean(&self, value: bool) -> Expression<'a> { Expression::new_boolean_literal(SPAN, value, self.builder) } @@ -73,6 +77,76 @@ impl<'b, 'a> Evaluations<'b, 'a> { } } +fn visit_style_value_expression<'a>( + builder: &AstBuilder<'a>, + style: &mut crate::ExtractStyleValue, + visit: &mut impl FnMut(&mut Expression<'a>), +) { + use crate::ExtractStyleValue; + match style { + ExtractStyleValue::Dynamic(value) => { + let mut expression = Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(value.identifier(), builder.allocator()), + builder, + ); + visit(&mut expression); + *value.identifier_mut() = crate::utils::readable_code(&expression); + } + ExtractStyleValue::Static(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => {} + } +} + +pub(super) fn visit_style_expressions<'a>( + builder: &AstBuilder<'a>, + style: &mut crate::ExtractStyleProp<'a>, + visit: &mut impl FnMut(&mut Expression<'a>), +) { + use crate::ExtractStyleProp; + match style { + ExtractStyleProp::Static(value) => visit_style_value_expression(builder, value, visit), + ExtractStyleProp::Unreadable { .. } => {} + ExtractStyleProp::StaticArray(styles) => { + for style in styles { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => { + visit(condition); + for style in consequent.iter_mut().chain(alternate.iter_mut()) { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Enum { condition, map } => { + visit(condition); + for style in map.values_mut().flatten() { + visit_style_expressions(builder, style, visit); + } + } + ExtractStyleProp::Expression { expression, styles } => { + visit(expression); + for style in styles { + visit_style_value_expression(builder, style, visit); + } + } + ExtractStyleProp::MemberExpression { expression, map } => { + visit(expression); + for style in map.values_mut() { + visit_style_expressions(builder, style, visit); + } + } + } +} + pub(super) fn bind_render<'a>( builder: &AstBuilder<'a>, bindings: Vec<(String, Expression<'a>)>, @@ -85,6 +159,10 @@ pub(super) fn bind_render<'a>( body } +#[cfg(test)] +#[path = "styled_record_evaluation_visitor_tests.rs"] +mod visitor_tests; + #[cfg(test)] mod tests { use boa_engine::{Context, Source}; diff --git a/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs new file mode 100644 index 000000000..8cc7b8dcf --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_evaluation_visitor_tests.rs @@ -0,0 +1,301 @@ +use std::collections::BTreeMap; + +use oxc_allocator::{Allocator, FromIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::SPAN; +use rstest::rstest; + +use super::visit_style_expressions; +use crate::{ + ExtractStyleProp, ExtractStyleValue, + extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, + }, + utils::readable_code, +}; + +#[derive(Clone, Copy)] +enum Container { + Conditional, + StaticArray, + Enum, + Expression, + MemberExpression, +} + +fn identifier<'a>(builder: &AstBuilder<'a>, name: &str) -> Expression<'a> { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name, builder.allocator()), + builder, + ) +} + +fn payload<'a>(builder: &AstBuilder<'a>, name: &str) -> ExtractStyleProp<'a> { + ExtractStyleProp::Expression { + expression: identifier(builder, name), + styles: vec![ + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new("color", 2, name, None)), + ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "background", + 4, + &format!("{name} + {name}.color !important"), + None, + )), + ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )), + ], + } +} + +fn assert_identifier(expression: &Expression<'_>, expected: &str) { + let Expression::Identifier(identifier) = expression else { + panic!("visitor must preserve the identifier expression"); + }; + assert_eq!(identifier.name.as_str(), expected); + assert_eq!(readable_code(expression), expected); +} + +fn assert_payload(style: &ExtractStyleProp<'_>, expected: &str) { + let ExtractStyleProp::Expression { expression, styles } = style else { + panic!("fixture payload must remain an Expression"); + }; + assert_identifier(expression, expected); + assert_eq!(styles.len(), 3); + let ExtractStyleValue::Dynamic(simple) = &styles[0] else { + panic!("first payload must remain dynamic"); + }; + assert_eq!(simple.identifier(), expected); + assert_eq!( + simple, + &ExtractDynamicStyle::new("color", 2, expected, None) + ); + let ExtractStyleValue::Dynamic(code) = &styles[1] else { + panic!("second payload must remain dynamic code"); + }; + assert_eq!(code.identifier(), format!("{expected} + {expected}.color")); + assert_eq!( + code, + &ExtractDynamicStyle::new( + "background", + 4, + &format!("{expected} + {expected}.color !important"), + None, + ) + ); + assert_eq!( + styles[2], + ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )) + ); +} + +#[test] +#[should_panic(expected = "visitor must preserve the identifier expression")] +fn identifier_guard_rejects_when_expression_is_null() { + // Given: an AST expression with the wrong shape. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let expression = Expression::new_null_literal(SPAN, &builder); + // When/Then: the existing identifier assertion rejects it at its shape guard. + assert_identifier(&expression, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "fixture payload must remain an Expression")] +fn payload_guard_rejects_when_container_is_an_array() { + // Given: a non-Expression container. + let style = ExtractStyleProp::StaticArray(vec![]); + // When/Then: the existing payload assertion rejects the container shape. + assert_payload(&style, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "first payload must remain dynamic")] +fn payload_guard_rejects_when_first_declaration_is_static() { + // Given: a valid payload with only its first declaration's shape changed. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut style = payload(&builder, "__devupRecord0"); + if let ExtractStyleProp::Expression { styles, .. } = &mut style { + styles[0] = styles[2].clone(); + } + // When/Then: earlier identifier/length checks pass, then the first guard rejects. + assert_payload(&style, "__devupRecord0"); +} + +#[test] +#[should_panic(expected = "second payload must remain dynamic code")] +fn payload_guard_rejects_when_second_declaration_is_static() { + // Given: a valid payload with only its second declaration's shape changed. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut style = payload(&builder, "__devupRecord0"); + if let ExtractStyleProp::Expression { styles, .. } = &mut style { + styles[1] = styles[2].clone(); + } + // When/Then: the first declaration checks pass, then the second guard rejects. + assert_payload(&style, "__devupRecord0"); +} + +#[rstest] +#[case::conditional(Container::Conditional)] +#[case::static_array(Container::StaticArray)] +#[case::enum_map(Container::Enum)] +#[case::expression(Container::Expression)] +#[case::member_map(Container::MemberExpression)] +fn payloads_are_rewritten_when_style_expressions_are_visited(#[case] container: Container) { + // Given: two distinct nested captures, including serialized dynamic code. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let first = payload(&builder, "__devupRecord0"); + let second = payload(&builder, "__devupRecord1"); + let control = identifier(&builder, "__devupRecord2"); + let mut style = match container { + Container::Conditional => ExtractStyleProp::Conditional { + condition: control, + consequent: Some(Box::new(first)), + alternate: Some(Box::new(second)), + }, + Container::StaticArray => ExtractStyleProp::StaticArray(vec![first, second]), + Container::Enum => ExtractStyleProp::Enum { + condition: control, + map: BTreeMap::from([ + ("__devupRecord0".to_string(), vec![first, second]), + ("empty".to_string(), vec![]), + ]), + }, + Container::Expression => first, + Container::MemberExpression => ExtractStyleProp::MemberExpression { + expression: control, + map: BTreeMap::from([ + ("__devupRecord0".to_string(), Box::new(first)), + ("second".to_string(), Box::new(second)), + ]), + }, + }; + + // When: the callback rewrites actual AST identifier references/code payloads. + visit_style_expressions(&builder, &mut style, &mut |expression| { + let Expression::Identifier(value) = expression else { + panic!("fixture visitor receives identifier expressions"); + }; + let renamed = value.name.replace("__devupRecord", "__devupInherited"); + value.name = oxc_ast::ast::Ident::from_in(renamed.as_str(), builder.allocator()); + }); + + // Then: every branch/map/array entry and AST control was rewritten. + match &style { + ExtractStyleProp::Conditional { + condition, + consequent, + alternate, + } => { + assert_identifier(condition, "__devupInherited2"); + let (Some(consequent), Some(alternate)) = (consequent, alternate) else { + panic!("both conditional branches must remain present"); + }; + assert_payload(consequent, "__devupInherited0"); + assert_payload(alternate, "__devupInherited1"); + } + ExtractStyleProp::StaticArray(styles) => { + assert_eq!(styles.len(), 2); + assert_payload(&styles[0], "__devupInherited0"); + assert_payload(&styles[1], "__devupInherited1"); + } + ExtractStyleProp::Enum { condition, map } => { + assert_identifier(condition, "__devupInherited2"); + assert_eq!(map.len(), 2); + assert_eq!(map["empty"].len(), 0); + assert_payload(&map["__devupRecord0"][0], "__devupInherited0"); + assert_payload(&map["__devupRecord0"][1], "__devupInherited1"); + } + ExtractStyleProp::Expression { .. } => assert_payload(&style, "__devupInherited0"), + ExtractStyleProp::MemberExpression { expression, map } => { + assert_identifier(expression, "__devupInherited2"); + assert_eq!(map.len(), 2); + assert_payload(&map["__devupRecord0"], "__devupInherited0"); + assert_payload(&map["second"], "__devupInherited1"); + } + ExtractStyleProp::Static(_) | ExtractStyleProp::Unreadable { .. } => { + panic!("container must remain unchanged"); + } + } +} + +#[test] +fn metadata_is_unchanged_when_style_expressions_are_visited() { + // Given: static declarations and unreadable diagnostics are not expressions. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let static_value = ExtractStyleValue::Static(ExtractStaticStyle::new( + "--authored", + "__devupRecord0", + 3, + None, + )); + let mut style = ExtractStyleProp::StaticArray(vec![ + ExtractStyleProp::Static(static_value.clone()), + ExtractStyleProp::Unreadable { + offset: 17, + code: "__devupRecord0".to_string(), + prop: true, + requirement: Some("a valid selector"), + }, + ExtractStyleProp::Static(ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 2, + "__devupRecord0", + None, + ))), + ]); + + // When: only the dynamic declaration is visited. + visit_style_expressions(&builder, &mut style, &mut |expression| { + assert_identifier(expression, "__devupRecord0"); + *expression = identifier(&builder, "__devupInherited0"); + }); + + // Then: metadata stays byte-identical and the dynamic declaration changes. + let ExtractStyleProp::StaticArray(styles) = style else { + panic!("array fixture"); + }; + let ExtractStyleProp::Static(value) = &styles[0] else { + panic!("static fixture"); + }; + assert_eq!(value, &static_value); + let ExtractStyleProp::Unreadable { + offset, + code, + prop, + requirement, + } = &styles[1] + else { + panic!("unreadable fixture"); + }; + assert_eq!( + (*offset, code.as_str(), *prop), + (17, "__devupRecord0", true) + ); + assert_eq!(*requirement, Some("a valid selector")); + let ExtractStyleProp::Static(value) = &styles[2] else { + panic!("dynamic fixture"); + }; + assert_eq!( + value, + &ExtractStyleValue::Dynamic(ExtractDynamicStyle::new( + "color", + 2, + "__devupInherited0", + None, + )) + ); +} diff --git a/libs/extractor/src/extractor/styled_record_tests.rs b/libs/extractor/src/extractor/styled_record_tests.rs index 4f999f15d..e9e2857e6 100644 --- a/libs/extractor/src/extractor/styled_record_tests.rs +++ b/libs/extractor/src/extractor/styled_record_tests.rs @@ -9,6 +9,196 @@ use rstest::rstest; use super::{styled_record::prepare, styled_record_evaluation::bind_render}; use crate::utils::readable_code; +#[rstest] +#[case::prepared_base("const Ext=styled(Base)({'p':1});", "", "4px")] +#[case::two_levels( + "const Mid=styled(Base)({'p':1});const Ext=styled(Mid)({'p':2});", + "", + "8px" +)] +#[case::tagged_extension("const Ext=styled(Base)`padding:4px`;", "", "4px")] +#[case::capture_collisions( + "const Mid=styled(Base)({backgroundColor:'white',...((trace.push('own'),own)?{backgroundColor:'black'}:{})});const Ext=styled(Mid)(__devupRecord0,{p:1,...((trace.push('authored'),__devupRecord1)?{p:2}:{})});", + ",own,__devupRecord0,__devupRecord1", + "8px" +)] +#[serial_test::serial] +fn inherited_captures_when_native_extension_renders( + #[case] extension: &str, + #[case] parameters: &str, + #[case] padding: &str, + #[values(false, true)] on: bool, + #[values(false, true)] authored: bool, + #[values(false, true)] single_css: bool, +) { + // Given: independent inherited, own and authored controllers. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(on{parameters}){{const Base=styled.div({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});{extension}return Ext({{id:'extension',className:'caller'}});}}const rendered=make({on},{},'authored',{authored});", + !on + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("extension fixture must extract: {error}"), + }; + let mut declarations = Vec::new(); + for style in &output.styles { + let crate::ExtractStyleValue::Static(value) = style else { + panic!("fixture declarations must be static"); + }; + let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + else { + panic!("fixture declaration must have a class"); + }; + declarations.push(format!( + "['{name}','{}','{}']", + value.property(), + value.value() + )); + } + // When: execute the emitted native render, not a generated-name assertion. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='authored'||name==='caller'),trace]);", + declarations.join(",") + ), + ); + // Then: only the selected declarations survive, with ordered single reads. + let color = if on { "blue" } else { "red" }; + let expected = if parameters.is_empty() { + format!( + r#"["div","extension",[["color","{color}"],["padding","{padding}"]],["caller"],["base"]]"# + ) + } else { + let background = if on { "white" } else { "black" }; + let padding = if authored { padding } else { "4px" }; + format!( + r#"["div","extension",[["background-color","{background}"],["color","{color}"],["padding","{padding}"]],["authored","caller"],["base","own","authored"]]"# + ) + }; + assert_eq!(rendered, expected); +} + +#[rstest] +#[serial_test::serial] +fn inherited_captures_when_dynamic_native_base_renders( + #[values(false, true)] on: bool, + #[values(false, true)] single_css: bool, +) { + // Given: a lowercase runtime target binding and an inherited captured guard. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(on,target){{const Base=styled(target)({{color:'red',...((trace.push('base'),on)?{{color:'blue'}}:{{}})}});const Ext=styled(Base)({{p:1}});return Ext({{id:'extension',className:'caller'}});}}const rendered=make({on},'div');" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("dynamic base extension must extract: {error}"), + }; + let mut declarations = Vec::new(); + for style in &output.styles { + let crate::ExtractStyleValue::Static(value) = style else { + panic!("fixture declarations must be static"); + }; + let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + else { + panic!("fixture declaration must have a class"); + }; + declarations.push(format!( + "['{name}','{}','{}']", + value.property(), + value.value() + )); + } + // When: execute the emitted extension with its actual runtime tag value. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');const styles=[{}].filter(([name])=>names.includes(name)).map(([,prop,value])=>[prop,value]).sort();JSON.stringify([rendered.type,rendered.props.id,styles,names.filter(name=>name==='caller'),trace]);", + declarations.join(",") + ), + ); + // Then: the tag, selected color, extension padding and single read survive. + let color = if on { "blue" } else { "red" }; + assert_eq!( + rendered, + format!( + r#"["div","extension",[["color","{color}"],["padding","4px"]],["caller"],["base"]]"# + ) + ); +} + +#[rstest] +#[serial_test::serial] +fn inherited_dependent_reads_when_attrs_and_later_styles_compose( + #[values(false, true)] outer: bool, + #[values(false, true)] inner: bool, + #[values(false, true)] single_css: bool, +) { + // Given: nested captured guards, a dynamic leaf and an authored attrs name. + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react';const trace=[];function make(outer,inner,__devupRecord0){{const Base=styled.div.attrs({{title:__devupRecord0,id:'base'}})({{color:'red',backgroundColor:'white',...((trace.push('outer'),outer)?{{...((trace.push('inner'),inner)?{{backgroundColor:(trace.push('leaf'),'gold')}}:{{}})}}:{{}})}});const Ext=styled(Base).attrs({{id:'extension'}})({{color:'orange'}});return Ext({{}});}}const rendered=make({outer},{inner},'authored-title');" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("dependent extension must extract: {error}"), + }; + let mut colors = Vec::new(); + for style in &output.styles { + if let crate::ExtractStyleValue::Static(value) = style + && value.property() == "color" + && let Some(crate::extract_style::style_property::StyleProperty::ClassName(name)) = + style.extract(if single_css { None } else { Some("test.tsx") }) + { + colors.push(format!("['{name}','{}']", value.value())); + } + } + // When: render the flattened extension through the existing native harness. + let rendered = evaluate_native_render( + &output.code, + &format!( + "const names=rendered.props.className.split(' ');JSON.stringify([rendered.props.id,rendered.props.title,[{}].filter(([name])=>names.includes(name)).map(([,value])=>value),Object.values(rendered.props.style||{{}}).filter(value=>value!=null),trace]);", + colors.join(",") + ), + ); + // Then: guarded reads stay single and ordered, attrs and later color win. + let expected = match (outer, inner) { + (true, true) => { + r#"["extension","authored-title",["orange"],["gold"],["outer","inner","leaf"]]"# + } + (true, false) => r#"["extension","authored-title",["orange"],[],["outer","inner"]]"#, + (false, _) => r#"["extension","authored-title",["orange"],[],["outer"]]"#, + }; + assert_eq!(rendered, expected); +} + fn evaluate_record(rules: &str, setup: &str) -> String { let allocator = Allocator::default(); let builder = AstBuilder::new(&allocator); @@ -287,7 +477,7 @@ fn original_record_when_unaffected_or_unreadable(#[case] rules: &str) { assert_eq!(readable_code(&statement.expression), original); } -/// Evaluate the emitted render scope, lowering only its empty native JSX element. +/// Evaluate the emitted render scope, lowering its empty native or bound JSX element. fn evaluate_native_render(code: &str, observation: &str) -> String { use oxc_ast::ast::{ ImportDeclaration, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElement, @@ -306,10 +496,17 @@ fn evaluate_native_render(code: &str, observation: &str) -> String { fn visit_jsx_element(&mut self, element: &JSXElement<'a>) { assert_eq!(element.children.len(), 0); - let JSXElementName::Identifier(tag) = &element.opening_element.name else { - panic!("fixture must render a native element"); + let tag = match &element.opening_element.name { + JSXElementName::Identifier(tag) => { + assert_eq!(tag.name, "div"); + "'div'" + } + JSXElementName::IdentifierReference(tag) => { + assert_eq!(tag.name, "DevupStyled"); + tag.name.as_str() + } + _ => panic!("fixture must render a native element"), }; - assert_eq!(tag.name, "div"); let props: Vec<_> = element .opening_element .attributes @@ -336,7 +533,7 @@ fn evaluate_native_render(code: &str, observation: &str) -> String { .collect(); self.0.push(( element.span, - format!("({{type:'div',props:{{{}}}}})", props.join(",")), + format!("({{type:{tag},props:{{{}}}}})", props.join(",")), )); } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index a600d1deb..82ad48295 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,55 @@ 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 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 +1527,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 +1901,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 +1920,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 a5d5afa82..c92f8a63f 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -2,6 +2,7 @@ mod as_visit; mod build_time_values; mod class_arguments; mod component; +mod composition; mod css_utils; mod emotion_namespace; pub mod extract_style; @@ -484,6 +485,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); @@ -14713,6 +14715,417 @@ 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] || \"\"}`" + )] + #[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""# + )] + #[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!( + "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_styled_extension_composes_base() { + let code = readable_code( + "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' }) +const Ordered = styled.div({ color: 'red', styleOrder: 3 })", + ); + for expected in [ + "const Ext = ({ style, className, ...rest }) => -
-
+
+
Option 1
-
+
Option 2
-
+
-
+
Option 3
-
+
Option 4
-
-