diff --git a/.changepacks/changepack_log_conditional_spread_precedence.json b/.changepacks/changepack_log_conditional_spread_precedence.json new file mode 100644 index 000000000..2512093ae --- /dev/null +++ b/.changepacks/changepack_log_conditional_spread_precedence.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "apps/landing/package.json": "Patch" + }, + "note": "Respect source-order property replacement across conditional spreads in styled rule objects, and document that styled callbacks cannot return runtime class names. No new build errors; existing located Cannot compose errors remain unchanged.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx index 0cb08360b..899beefde 100644 --- a/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx +++ b/apps/landing/src/app/(detail)/docs/migration/styled-components/page.mdx @@ -40,6 +40,8 @@ A style argument can be conditional — `cond && { ... }`, `cond ? { ... } : { . A style argument that cannot be known at build time — a function call the build cannot run, a spread or a computed key it cannot compute — is a build error naming it, rather than a silently missing style; a spread, a selector value or a computed key the module computes (`styled.div({ ...cardStyles(), _hover: hoverStyles })`) is computed, while the other values stay CSS variables set at runtime. An object constant, declared in the file or imported, reads as if written in place: `styled.div(baseStyles)`, `styled.div({ ...baseStyles, color: 'red' })`. A single argument that is not a rule object composes as a class, as several do; one holding rules the module computes (`const base = makeStyles()`, `styled.div(base)`) applies those rules. In the object form a value may read the theme (`(p) => p.theme.brand`); any other function of the props is a build error — write it in a template (`${(p) => p.color}`), where it becomes a CSS variable. +A styled callback cannot return a class name known only at runtime (`styled.div(p => { return p.cls; })`): the build reports a located `Cannot compose` error. A condition choosing between known classes or rule objects is supported. + Inside a template, an interpolation standing where a declaration would is a mixin: `${mixin}` and `${(p) => p.active && mixin}` compose their class with the component's. A literal or constant is written into the CSS wherever it stands — a value, a CSS snippet, a selector. Any other interpolation in a selector or a property name is a build error, as are runtime values in `css`, `keyframes` and global styles, which have no element to set a CSS variable on. `.attrs()` and `.withConfig()` chain onto any factory. Attrs are merged over the props in order, as styled-components does — an object is spread, a function is called with the props; `.withConfig()` only tunes runtime behavior, so it is dropped: diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 425ed0245..7c22c4c1a 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -8,6 +8,8 @@ use crate::{ extractor::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, + styled_record::prepare, + styled_record_evaluation::bind_render, }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, @@ -184,6 +186,7 @@ pub fn extract_style_from_styled<'a>( imports: &FxHashMap, attrs: &[Expression<'a>], ) -> (ExtractResult<'a>, Expression<'a>, Vec<(u32, String)>) { + let original_scope = expression.clone_in(ast_builder.allocator()); let mut composed_classes = Vec::new(); let mut errors = Vec::new(); if let Expression::CallExpression(call) = expression @@ -277,8 +280,11 @@ pub fn extract_style_from_styled<'a>( let component = create_styled_component( ast_builder, &base.name, - &class_name, - &gen_styles(ast_builder, &props_styles, None), + StyledRender { + class_name: &class_name, + style_vars: &gen_styles(ast_builder, &props_styles, None), + bindings: Vec::new(), + }, ); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); @@ -298,6 +304,16 @@ pub fn extract_style_from_styled<'a>( // Case 2: styled.div({ bg: "red" }), styled("div")({ bg: "red" }), // or styled("div", { bg: "red" }) + let prepared = call.arguments[style_index] + .as_expression() + .and_then(|rules| prepare(ast_builder, rules, &original_scope)); + let bindings = 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, @@ -340,8 +356,11 @@ pub fn extract_style_from_styled<'a>( let component = create_styled_component( ast_builder, &base.name, - &class_name, - &gen_styles(ast_builder, &styles, None), + StyledRender { + class_name: &class_name, + style_vars: &gen_styles(ast_builder, &styles, None), + bindings, + }, ); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, attrs)); @@ -540,12 +559,22 @@ fn props_arrow<'a>(ast_builder: &AstBuilder<'a>, body: Expression<'a>) -> Expres ) } +struct StyledRender<'b, 'a> { + class_name: &'b Option>, + style_vars: &'b Option>, + bindings: Vec<(String, Expression<'a>)>, +} + fn create_styled_component<'a>( ast_builder: &AstBuilder<'a>, tag_name: &str, - class_name: &Option>, - style_vars: &Option>, + render: StyledRender<'_, 'a>, ) -> Expression<'a> { + let StyledRender { + class_name, + style_vars, + bindings, + } = render; let params = FormalParameters::boxed( SPAN, FormalParameterKind::ArrowFormalParameters, @@ -692,7 +721,7 @@ fn create_styled_component<'a>( None::>>, params, None::>>, - body.into(), + bind_render(ast_builder, bindings, body).into(), ast_builder, ) } diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 1b47b9788..3678e793f 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,6 +9,10 @@ pub(super) mod extract_style_from_jsx; pub(super) mod extract_style_from_member_expression; pub(super) mod extract_style_from_styled; pub(super) mod extract_style_from_stylex; +mod styled_record; +mod styled_record_evaluation; +#[cfg(test)] +mod styled_record_tests; /** * type diff --git a/libs/extractor/src/extractor/styled_record.rs b/libs/extractor/src/extractor/styled_record.rs new file mode 100644 index 000000000..5c174a18b --- /dev/null +++ b/libs/extractor/src/extractor/styled_record.rs @@ -0,0 +1,281 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectProperty, ObjectPropertyKind, PropertyKind}, + builder::AstBuilder, +}; +use oxc_span::GetSpan; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator}; + +use super::styled_record_evaluation::Evaluations; +use crate::utils::{ + get_str_by_property_key, get_string_by_literal_expression, readable_code, unwrap_syntax_only, +}; + +struct Entry<'a> { + key: String, + property: oxc_allocator::Box<'a, ObjectProperty<'a>>, + present: Expression<'a>, + conditional: bool, +} + +/// A prepared record is committed only after every spread and key was readable. +pub(super) struct PreparedRecord<'a> { + pub rules: Expression<'a>, + pub bindings: Vec<(String, Expression<'a>)>, +} + +struct Preparation<'b, 'a> { + reads: Evaluations<'b, 'a>, + affected: bool, +} + +pub(super) fn prepare<'a>( + builder: &AstBuilder<'a>, + rules: &Expression<'a>, + scope: &Expression<'a>, +) -> Option> { + let mut preparation = Preparation { + reads: Evaluations::new(builder, readable_code(scope)), + affected: false, + }; + let entries = preparation.record(rules, &preparation.reads.boolean(true))?; + if !preparation.affected { + return None; + } + Some(PreparedRecord { + rules: preparation.materialize(rules.span(), entries), + bindings: preparation.reads.bindings, + }) +} + +impl<'a> Preparation<'_, 'a> { + fn record( + &mut self, + source: &Expression<'a>, + guard: &Expression<'a>, + ) -> Option>> { + match unwrap_syntax_only(source) { + Expression::ObjectExpression(object) => { + let mut entries = Vec::new(); + for property in &object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + let key = get_str_by_property_key(&property.key)?.into_owned(); + if property.method || property.kind != PropertyKind::Init { + return None; + } + let mut property = property.clone_in(self.reads.builder.allocator()); + if !matches!( + key.as_str(), + "styleOrder" | "styleVars" | "props" | "params" | "as" + ) { + property.value = self.value(&property.value, guard)?; + } + property.shorthand = false; + let entry = Entry { + key, + property, + present: self.reads.boolean(true), + conditional: false, + }; + self.append(&mut entries, entry); + } + ObjectPropertyKind::SpreadProperty(spread) => { + for entry in self.record(&spread.argument, guard)? { + self.append(&mut entries, entry); + } + } + } + } + Some(entries) + } + Expression::ConditionalExpression(choice) => { + let test = self.reads.capture(&choice.test, guard); + let yes_guard = self.reads.choose(guard, &test, &self.reads.boolean(false)); + let no_guard = self.reads.choose( + guard, + &self.reads.choose( + &test, + &self.reads.boolean(false), + &self.reads.boolean(true), + ), + &self.reads.boolean(false), + ); + let yes = self.record(&choice.consequent, &yes_guard)?; + let no = self.record(&choice.alternate, &no_guard)?; + Some(self.choice(&test, yes, no)) + } + Expression::LogicalExpression(logical) if logical.operator == LogicalOperator::And => { + let test = self.reads.capture(&logical.left, guard); + let branch_guard = self.reads.choose(guard, &test, &self.reads.boolean(false)); + let yes = self.record(&logical.right, &branch_guard)?; + Some(self.choice(&test, yes, Vec::new())) + } + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) => Some(Vec::new()), + Expression::Identifier(identifier) if identifier.name == "undefined" => { + Some(Vec::new()) + } + _ => None, + } + } + + fn append(&mut self, entries: &mut Vec>, mut next: Entry<'a>) { + if let Some(index) = entries.iter().position(|entry| entry.key == next.key) { + let previous = entries.remove(index); + self.affected |= previous.conditional || next.conditional; + next.property.value = self.reads.choose( + &next.present, + &next.property.value, + &previous.property.value, + ); + next.present = + self.reads + .choose(&next.present, &self.reads.boolean(true), &previous.present); + next.conditional |= previous.conditional; + } + entries.push(next); + } + + fn choice( + &self, + test: &Expression<'a>, + yes: Vec>, + mut no: Vec>, + ) -> Vec> { + let mut result = Vec::new(); + for mut entry in yes { + let other = no + .iter() + .position(|other| other.key == entry.key) + .map(|index| no.remove(index)); + let (present, value) = other.map_or_else( + || (self.reads.boolean(false), self.reads.undefined()), + |other| { + ( + other.present, + other + .property + .value + .clone_in(self.reads.builder.allocator()), + ) + }, + ); + entry.present = self.reads.choose(test, &entry.present, &present); + entry.property.value = self.reads.choose(test, &entry.property.value, &value); + entry.conditional = true; + result.push(entry); + } + for mut entry in no { + entry.present = self + .reads + .choose(test, &self.reads.boolean(false), &entry.present); + entry.property.value = + self.reads + .choose(test, &self.reads.undefined(), &entry.property.value); + entry.conditional = true; + result.push(entry); + } + result + } + + fn materialize(&self, span: oxc_span::Span, entries: Vec>) -> Expression<'a> { + let properties = entries.into_iter().map(|mut entry| { + entry.property.value = self.reads.choose( + &entry.present, + &entry.property.value, + &self.reads.undefined(), + ); + ObjectPropertyKind::ObjectProperty(entry.property) + }); + Expression::new_object_expression( + span, + oxc_allocator::Vec::from_iter_in(properties, self.reads.builder), + self.reads.builder, + ) + } + + fn value(&mut self, source: &Expression<'a>, guard: &Expression<'a>) -> Option> { + let source = unwrap_syntax_only(source); + let builder = self.reads.builder; + if get_string_by_literal_expression(source).is_some() { + return Some(source.clone_in(builder.allocator())); + } + match source { + Expression::ObjectExpression(_) => { + let entries = self.record(source, guard)?; + Some(self.materialize(source.span(), entries)) + } + Expression::ConditionalExpression(choice) => { + let test = self.reads.capture(&choice.test, guard); + let yes_guard = self.reads.choose(guard, &test, &self.reads.boolean(false)); + let no_guard = self.reads.choose( + guard, + &self.reads.choose( + &test, + &self.reads.boolean(false), + &self.reads.boolean(true), + ), + &self.reads.boolean(false), + ); + let yes = self.value(&choice.consequent, &yes_guard)?; + let no = self.value(&choice.alternate, &no_guard)?; + Some(self.reads.choose(&test, &yes, &no)) + } + Expression::LogicalExpression(logical) => { + let left = self.reads.capture(&logical.left, guard); + let takes_right = match logical.operator { + LogicalOperator::And => left.clone_in(builder.allocator()), + LogicalOperator::Or => self.reads.choose( + &left, + &self.reads.boolean(false), + &self.reads.boolean(true), + ), + LogicalOperator::Coalesce => Expression::new_binary_expression( + source.span(), + left.clone_in(builder.allocator()), + BinaryOperator::Equality, + Expression::new_null_literal(source.span(), builder), + builder, + ), + }; + let right_guard = + self.reads + .choose(guard, &takes_right, &self.reads.boolean(false)); + let right = self.value(&logical.right, &right_guard)?; + Some(Expression::new_logical_expression( + source.span(), + left, + logical.operator, + right, + builder, + )) + } + Expression::ArrayExpression(array) => { + let mut array = array.clone_in(builder.allocator()); + for element in &mut array.elements { + if let Some(value) = element.as_expression_mut() { + *value = self.value(value, guard)?; + } else if matches!( + element, + oxc_ast::ast::ArrayExpressionElement::SpreadElement(_) + ) { + return None; + } + } + Some(Expression::ArrayExpression(array)) + } + Expression::NullLiteral(_) + | Expression::BigIntLiteral(_) + | Expression::RegExpLiteral(_) + | Expression::FunctionExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::ClassExpression(_) => Some(source.clone_in(builder.allocator())), + Expression::Identifier(identifier) + if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") => + { + Some(source.clone_in(builder.allocator())) + } + _ => Some(self.reads.capture(source, guard)), + } + } +} diff --git a/libs/extractor/src/extractor/styled_record_evaluation.rs b/libs/extractor/src/extractor/styled_record_evaluation.rs new file mode 100644 index 000000000..6a2ac15c8 --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_evaluation.rs @@ -0,0 +1,148 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::{GetSpan, SPAN}; + +use crate::utils::{call_with_values, is_same_expression}; + +/// Ordered reads shared by the class and variable expressions of one render. +pub(super) struct Evaluations<'b, 'a> { + pub builder: &'b AstBuilder<'a>, + source: String, + pub bindings: Vec<(String, Expression<'a>)>, + next: usize, +} + +impl<'b, 'a> Evaluations<'b, 'a> { + pub const fn new(builder: &'b AstBuilder<'a>, source: String) -> Self { + Self { + builder, + source, + bindings: Vec::new(), + next: 0, + } + } + + 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 value = self.choose(guard, value, &self.undefined()); + self.bindings.push((name.clone(), value)); + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), self.builder.allocator()), + self.builder, + ) + } + + pub fn boolean(&self, value: bool) -> Expression<'a> { + Expression::new_boolean_literal(SPAN, value, self.builder) + } + + pub fn undefined(&self) -> Expression<'a> { + Expression::new_identifier(SPAN, "undefined", self.builder) + } + + pub fn choose( + &self, + test: &Expression<'a>, + yes: &Expression<'a>, + no: &Expression<'a>, + ) -> Expression<'a> { + let clone = |value: &Expression<'a>| value.clone_in(self.builder.allocator()); + match (test, yes, no) { + (Expression::BooleanLiteral(value), _, _) => clone(if value.value { yes } else { no }), + (_, Expression::BooleanLiteral(yes), Expression::BooleanLiteral(no)) + if yes.value == no.value => + { + self.boolean(yes.value) + } + _ if is_same_expression(yes, no) => clone(yes), + _ => Expression::new_conditional_expression( + test.span(), + clone(test), + clone(yes), + clone(no), + self.builder, + ), + } + } +} + +pub(super) fn bind_render<'a>( + builder: &AstBuilder<'a>, + bindings: Vec<(String, Expression<'a>)>, + mut body: Expression<'a>, +) -> Expression<'a> { + // Dependent arguments must be inside the scope of the preceding read. + for binding in bindings.into_iter().rev() { + body = call_with_values(builder, vec![binding], body); + } + body +} + +#[cfg(test)] +mod tests { + use boa_engine::{Context, Source}; + use oxc_allocator::Allocator; + use oxc_ast::ast::Statement; + use oxc_parser::Parser; + use oxc_span::SourceType; + use rstest::rstest; + + use super::*; + use crate::utils::readable_code; + + #[rstest] + #[case::equal_true(true, true)] + #[case::equal_false(false, false)] + #[case::different_true(true, false)] + #[case::different_false(false, true)] + fn boolean_choice_when_test_is_captured( + #[case] yes: bool, + #[case] no: bool, + #[values(false, true)] on: bool, + ) { + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = "(trace.push('test'), on);"; + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("test fixture must be an expression"); + }; + let mut reads = Evaluations::new(&builder, source.to_string()); + let test = reads.capture(&statement.expression, &reads.boolean(true)); + let choice = reads.choose(&test, &reads.boolean(yes), &reads.boolean(no)); + match &choice { + Expression::BooleanLiteral(value) => { + assert_eq!(yes, no); + assert_eq!(value.value, yes); + } + Expression::ConditionalExpression(_) => assert_ne!(yes, no), + _ => panic!("Boolean choice must be constant or conditional"), + } + let render = bind_render(&builder, reads.bindings, choice); + let script = format!( + "const trace = []; const on = {on}; const result = {}; JSON.stringify([result, trace]);", + readable_code(&render) + ); + let mut context = Context::default(); + let result = match context.eval(Source::from_bytes(script.as_bytes())) { + Ok(result) => result, + Err(error) => panic!("Boolean choice evaluation failed: {error}"), + }; + let result = match result.as_string() { + Some(result) => result.to_std_string_escaped(), + None => panic!("Boolean choice must return a JSON string"), + }; + assert_eq!( + result, + format!(r#"[{},["test"]]"#, if on { yes } else { no }) + ); + } +} diff --git a/libs/extractor/src/extractor/styled_record_tests.rs b/libs/extractor/src/extractor/styled_record_tests.rs new file mode 100644 index 000000000..4f999f15d --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_tests.rs @@ -0,0 +1,489 @@ +use boa_engine::{Context, Source}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use oxc_allocator::Allocator; +use oxc_ast::{ast::Statement, builder::AstBuilder}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::{styled_record::prepare, styled_record_evaluation::bind_render}; +use crate::utils::readable_code; + +fn evaluate_record(rules: &str, setup: &str) -> String { + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = format!("({rules});"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("record fixture must be an expression"); + }; + let prepared = match prepare(&builder, &statement.expression, &statement.expression) { + Some(prepared) => prepared, + None => panic!("record fixture must require preparation"), + }; + let render = bind_render(&builder, prepared.bindings, prepared.rules); + let script = format!( + "const trace = []; {setup}; const result = {}; JSON.stringify([Object.keys(result), result, trace]);", + readable_code(&render) + ); + let mut context = Context::default(); + let result = match context.eval(Source::from_bytes(script.as_bytes())) { + Ok(result) => result, + Err(error) => panic!("record evaluation failed: {error}"), + }; + match result.as_string() { + Some(result) => result.to_std_string_escaped(), + None => panic!("record evaluation must return a JSON string"), + } +} + +#[rstest] +#[case::later_explicit( + "{ ...(a ? { color: 'red' } : { color: 'purple' }), color: 'orange' }", + "const a = true", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::missing_true( + "{ color: 'orange', ...(a ? {} : { color: 'purple' }) }", + "const a = true", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::missing_false( + "{ color: 'orange', ...(a ? { color: 'red' } : {}) }", + "const a = false", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::supplied_undefined( + "{ color: 'orange', ...(a ? { color: undefined } : {}) }", + "const a = true", + r#"[["color"],{},[]]"# +)] +#[case::later_spread( + "{ ...(a ? { color: 'red' } : {}), color: 'orange', ...(b ? { color: 'blue' } : {}) }", + "const a = true, b = true", + r#"[["color"],{"color":"blue"},[]]"# +)] +#[case::later_spread_missing( + "{ ...(a ? { color: 'red' } : {}), color: 'orange', ...(b ? { color: 'blue' } : {}) }", + "const a = false, b = false", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::nested_missing( + "{ color: 'orange', ...(a ? { ...(b ? { color: 'blue' } : {}) } : {}) }", + "const a = true, b = false", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::nested_supplied( + "{ color: 'orange', ...(a ? { ...(b ? { color: 'blue' } : {}) } : {}) }", + "const a = true, b = true", + r#"[["color"],{"color":"blue"},[]]"# +)] +#[case::logical_present( + "{ color: 'orange', ...(a && { color: 'red' }) }", + "const a = true", + r#"[["color"],{"color":"red"},[]]"# +)] +#[case::logical_missing( + "{ color: 'orange', ...(a && { color: 'red' }) }", + "const a = false", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::null_branch( + "{ color: 'orange', ...(a ? null : { color: 'red' }) }", + "const a = true", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::undefined_branch( + "{ color: 'orange', ...(a ? { color: 'red' } : undefined) }", + "const a = false", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::false_branch( + "{ color: 'orange', ...(a ? false : { color: 'red' }) }", + "const a = true", + r#"[["color"],{"color":"orange"},[]]"# +)] +#[case::shallow_selector( + "{ _hover: { color: 'red', backgroundColor: 'yellow' }, ...(a ? { _hover: { color: 'blue' } } : {}) }", + "const a = true", + r#"[["_hover"],{"_hover":{"color":"blue"}},[]]"# +)] +#[case::selector_fallback( + "{ _hover: { color: 'red', backgroundColor: 'yellow' }, ...(a ? { _hover: { color: 'blue' } } : {}) }", + "const a = false", + r#"[["_hover"],{"_hover":{"color":"red","backgroundColor":"yellow"}},[]]"# +)] +#[case::responsive_replace( + "{ color: ['red', null, 'green'], ...(a ? { color: ['blue'] } : {}) }", + "const a = true", + r#"[["color"],{"color":["blue"]},[]]"# +)] +#[case::responsive_fallback( + "{ color: ['red', null, 'green'], ...(a ? { color: ['blue'] } : {}) }", + "const a = false", + r#"[["color"],{"color":["red",null,"green"]},[]]"# +)] +#[case::wrapped( + "{ color: 'orange', ...((a ? { color: 'red' } : {}) satisfies object) }", + "const a = true", + r#"[["color"],{"color":"red"},[]]"# +)] +#[case::nested_selector( + "{ _hover: { color: 'orange', ...(a ? { color: 'red' } : {}) } }", + "const a = false", + r#"[["_hover"],{"_hover":{"color":"orange"}},[]]"# +)] +#[case::literal_spread( + "{ ...{ color: 'orange', p: 1 }, ...(a ? { color: 'red' } : {}), ...{ color: 'blue' } }", + "const a = true", + r#"[["p","color"],{"p":1,"color":"blue"},[]]"# +)] +#[case::selector_text( + "{ _hover: { color: 'red' }, ...(a ? { _hover: `color:blue` } : {}) }", + "const a = true", + r#"[["_hover"],{"_hover":"color:blue"},[]]"# +)] +#[case::literal_template_key( + "{ ...(a ? { color: 'red' } : {}), [`co${'lor'}`]: 'orange' }", + "const a = true", + r#"[["color"],{"color":"orange"},[]]"# +)] +fn record_precedence_when_branch_presence_varies( + #[case] rules: &str, + #[case] setup: &str, + #[case] expected: &str, +) { + let result = evaluate_record(rules, setup); + assert_eq!(result, expected); +} + +#[rstest] +#[case::overwritten( + "{ color: (trace.push('before'), true) ? 'purple' : 'navy', ...((trace.push('spread'), true) ? { color: 'red' } : {}), color: (trace.push('after'), 'orange') }", + "", + r#"[["color"],{"color":"orange"},["before","spread","after"]]"# +)] +#[case::literal_computed_overwritten( + "{ ...((trace.push('spread'), true) ? { ['color']: (trace.push('leaf'), 'red') } : {}), color: (trace.push('after'), 'orange') }", + "", + r#"[["color"],{"color":"orange"},["spread","leaf","after"]]"# +)] +#[case::literal_computed_unselected( + "{ ...((trace.push('spread'), false) ? { ['color']: (trace.push('leaf'), 'red') } : {}), color: (trace.push('after'), 'orange') }", + "", + r#"[["color"],{"color":"orange"},["spread","after"]]"# +)] +#[case::literal_computed_selected( + "{ color: 'orange', ...((trace.push('spread'), true) ? { ['color']: 'red' } : {}) }", + "", + r#"[["color"],{"color":"red"},["spread"]]"# +)] +#[case::template_computed_overwritten( + "{ ...((trace.push('spread'), true) ? { [`color`]: (trace.push('leaf'), 'red') } : {}), [`color`]: (trace.push('after'), 'orange') }", + "", + r#"[["color"],{"color":"orange"},["spread","leaf","after"]]"# +)] +#[case::template_computed_unselected( + "{ ...((trace.push('spread'), false) ? { [`color`]: (trace.push('leaf'), 'red') } : {}), color: (trace.push('after'), 'orange') }", + "", + r#"[["color"],{"color":"orange"},["spread","after"]]"# +)] +#[case::template_computed_selected( + "{ color: 'orange', ...((trace.push('spread'), true) ? { [`color`]: 'red' } : {}) }", + "", + r#"[["color"],{"color":"red"},["spread"]]"# +)] +#[case::branch_guard( + "{ color: 'orange', ...((trace.push('outer'), false) ? { ...((trace.push('inner'), true) ? { color: (trace.push('leaf'), 'red') } : {}) } : {}) }", + "", + r#"[["color"],{"color":"orange"},["outer"]]"# +)] +#[case::selected_nested( + "{ color: 'orange', ...((trace.push('outer'), true) ? { ...((trace.push('inner'), true) ? { color: (trace.push('leaf'), 'red') } : {}) } : {}) }", + "", + r#"[["color"],{"color":"red"},["outer","inner","leaf"]]"# +)] +#[case::getter_occurrences( + "{ color: 'orange', ...(state.on ? { color: 'red', bg: 'white' } : {}), ...(state.on ? { color: 'blue' } : {}) }", + "const state = { get on() { trace.push('get'); return true } }", + r#"[["bg","color"],{"bg":"white","color":"blue"},["get","get"]]"# +)] +#[case::captured_identifier( + "{ color: 'orange', ...(on ? { color: 'red' } : {}), ...((on = false) ? { color: 'blue' } : {}) }", + "let on = true", + r#"[["color"],{"color":"red"},[]]"# +)] +#[case::name_collision( + "{ color: 'orange', ...(__devupRecord0 ? { color: __devupRecord1 } : {}) }", + "const __devupRecord0 = true, __devupRecord1 = 'red'", + r#"[["color"],{"color":"red"},[]]"# +)] +#[case::equal_branches( + "{ color: (trace.push('before'), true) ? 'red' : 'red', ...((trace.push('spread'), true) ? { color: 'red' } : {}), color: 'orange' }", + "", + r#"[["color"],{"color":"orange"},["before","spread"]]"# +)] +#[case::value_logical( + "{ color: 'orange', ...(on ? { color: 'red' } : {}), p: (trace.push('left'), true) && (trace.push('right'), 2) }", + "const on = false", + r#"[["color","p"],{"color":"orange","p":2},["left","right"]]"# +)] +#[case::value_or( + "{ color: 'orange', ...(on ? { color: 'red' } : {}), p: (trace.push('left'), false) || (trace.push('right'), 2) }", + "const on = false", + r#"[["color","p"],{"color":"orange","p":2},["left","right"]]"# +)] +#[case::value_nullish( + "{ color: 'orange', ...(on ? { color: 'red' } : {}), p: (trace.push('left'), null) ?? (trace.push('right'), 2) }", + "const on = false", + r#"[["color","p"],{"color":"orange","p":2},["left","right"]]"# +)] +#[case::logical_skipped( + "{ color: 'orange', ...(on ? { color: 'red' } : {}), p: (trace.push('left'), false) && (trace.push('right'), 2) }", + "const on = false", + r#"[["color","p"],{"color":"orange","p":false},["left"]]"# +)] +#[case::branch_alternate( + "{ color: 'orange', ...((trace.push('outer'), false) ? {} : { color: (trace.push('leaf'), 'blue') }) }", + "", + r#"[["color"],{"color":"blue"},["outer","leaf"]]"# +)] +fn record_reads_when_relocated_or_overwritten( + #[case] rules: &str, + #[case] setup: &str, + #[case] expected: &str, +) { + let result = evaluate_record(rules, setup); + assert_eq!(result, expected); +} + +#[rstest] +#[case::plain("{ color: 'red' }")] +#[case::unconditional("{ ...{ color: 'red' }, color: 'orange' }")] +#[case::distinct("{ ...(on ? { color: 'red' } : {}), p: 1 }")] +#[case::computed_unaffected("{ ...(on ? { ['color']: 'red' } : {}), p: 1 }")] +#[case::unknown_spread("{ color: 'orange', ...(on ? { color: 'red' } : {}), ...unknown }")] +#[case::unknown_branch("{ color: 'orange', ...(on ? unknown : { color: 'red' }) }")] +#[case::computed_key("{ color: 'orange', ...(on ? { [key]: 'red' } : {}) }")] +#[case::computed_effect("{ color: 'orange', ...(on ? { [(read(), 'color')]: 'red' } : {}) }")] +#[case::computed_template_unknown("{ color: 'orange', ...(on ? { [`${key}`]: 'red' } : {}) }")] +#[case::computed_template_effect("{ color: 'orange', ...(on ? { [`${read()}`]: 'red' } : {}) }")] +#[case::computed_getter("{ color: 'orange', ...(on ? { get ['color']() { return 'red' } } : {}) }")] +#[case::computed_method("{ color: 'orange', ...(on ? { ['color']() { return 'red' } } : {}) }")] +#[case::getter("{ color: 'orange', ...(on ? { get color() { return 'red' } } : {}) }")] +#[case::method("{ color: 'orange', ...(on ? { color() { return 'red' } } : {}) }")] +#[case::array_spread("{ color: 'orange', ...(on ? { color: [...values] } : {}) }")] +fn original_record_when_unaffected_or_unreadable(#[case] rules: &str) { + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = format!("({rules});"); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("record fixture must be an expression"); + }; + let original = readable_code(&statement.expression); + assert!(prepare(&builder, &statement.expression, &statement.expression).is_none()); + assert_eq!(readable_code(&statement.expression), original); +} + +/// Evaluate the emitted render scope, lowering only its empty native JSX element. +fn evaluate_native_render(code: &str, observation: &str) -> String { + use oxc_ast::ast::{ + ImportDeclaration, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXElement, + JSXElementName, + }; + use oxc_ast_visit::Visit; + use oxc_span::Span; + + #[derive(Default)] + struct NativeElements(Vec<(Span, String)>); + + impl<'a> Visit<'a> for NativeElements { + fn visit_import_declaration(&mut self, import: &ImportDeclaration<'a>) { + self.0.push((import.span, String::new())); + } + + 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"); + }; + assert_eq!(tag.name, "div"); + let props: Vec<_> = element + .opening_element + .attributes + .iter() + .map(|attribute| match attribute { + JSXAttributeItem::SpreadAttribute(spread) => { + format!("...{}", readable_code(&spread.argument)) + } + JSXAttributeItem::Attribute(attribute) => { + let JSXAttributeName::Identifier(name) = &attribute.name else { + panic!("fixture must have identifier attributes"); + }; + let Some(JSXAttributeValue::ExpressionContainer(value)) = &attribute.value + else { + panic!("fixture must have expression attributes"); + }; + let value = match value.expression.as_expression() { + Some(value) => value, + None => panic!("fixture attribute must contain an expression"), + }; + format!("{}:({})", name.name, readable_code(value)) + } + }) + .collect(); + self.0.push(( + element.span, + format!("({{type:'div',props:{{{}}}}})", props.join(",")), + )); + } + } + + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let mut elements = NativeElements::default(); + elements.visit_program(&parsed.program); + elements + .0 + .sort_unstable_by_key(|(span, _)| std::cmp::Reverse(span.start)); + let mut script = code.to_string(); + for (span, replacement) in elements.0 { + let (start, end) = match (usize::try_from(span.start), usize::try_from(span.end)) { + (Ok(start), Ok(end)) => (start, end), + _ => panic!("fixture spans must fit native indices"), + }; + script.replace_range(start..end, &replacement); + } + script.push_str(observation); + let mut context = Context::default(); + let result = match context.eval(Source::from_bytes(script.as_bytes())) { + Ok(result) => result, + Err(error) => panic!("native render evaluation failed: {error}"), + }; + match result.as_string() { + Some(result) => result.to_std_string_escaped(), + None => panic!("native observation must return JSON"), + } +} + +#[rstest] +#[serial_test::serial] +fn template_key_precedence_when_public_component_renders( + #[values(false, true)] single_css: bool, + #[values(false, true)] on: bool, +) { + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react'; const trace=[]; const on={on}; const Card=styled('div',{{...((trace.push('spread'),on)?{{[`color`]:(trace.push('leaf'),'red')}}:{{color:'purple'}}),[`color`]:'orange'}}); const rendered=Card({{id:'card'}});" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("static template key extraction failed: {error}"), + }; + let values: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => (style.property(), style.value()), + _ => panic!("winning template key must remain static"), + }) + .collect(); + assert_eq!(values, vec![("color", "orange")]); + let rendered = evaluate_native_render( + &output.code, + "JSON.stringify([rendered.type,rendered.props.id,trace]);", + ); + assert_eq!( + rendered, + if on { + r#"["div","card",["spread","leaf"]]"# + } else { + r#"["div","card",["spread"]]"# + } + ); +} + +#[rstest] +#[case::padding(("p", "+2", "padding", "8px"))] +#[case::margin(("m", "-2", "margin", "-8px"))] +#[case::quoted_padding(("p", "+'2'", "padding", "8px"))] +#[case::template_margin(("m", "-`2`", "margin", "-8px"))] +#[case::interpolated_padding(("p", "`${2}`", "padding", "8px"))] +#[serial_test::serial] +fn signed_shorthand_when_public_record_is_prepared( + #[case] shorthand: (&str, &str, &str, &str), + #[values(false, true)] single_css: bool, +) { + let (property, literal, css_property, expected) = shorthand; + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react'; const Card=styled.div({{...(on?{{color:'red'}}:{{}}),color:'orange',{property}:{literal}}});" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("signed shorthand extraction failed: {error}"), + }; + let mut values: Vec<_> = output + .styles + .iter() + .map(|style| match style { + crate::ExtractStyleValue::Static(style) => (style.property(), style.value()), + _ => panic!("literal shorthand must not become a dynamic variable"), + }) + .collect(); + values.sort_unstable(); + let mut expected = vec![("color", "orange"), (css_property, expected)]; + expected.sort_unstable(); + assert_eq!(values, expected); +} + +#[rstest] +#[serial_test::serial] +fn authored_composed_class_when_public_component_renders( + #[values(false, true)] single_css: bool, + #[values(false, true)] on: bool, +) { + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{styled}} from '@devup-ui/react'; function make(on,__devupRecord0){{const Card=styled.div(__devupRecord0,{{...(on?{{color:'red'}}:{{}}),color:'orange'}});return Card({{id:'card',className:'caller-class'}});}} const rendered=make({on},'authored-class');" + ); + let output = match crate::extract( + "test.tsx", + &code, + crate::ExtractOption { + single_css, + ..crate::ExtractOption::default() + }, + ) { + Ok(output) => output, + Err(error) => panic!("composed class extraction failed: {error}"), + }; + let rendered = evaluate_native_render( + &output.code, + "JSON.stringify([rendered.type,rendered.props.id,rendered.props.className.split(' ').filter(name=>name==='authored-class'||name==='caller-class')]);", + ); + assert_eq!( + rendered, + r#"["div","card",["authored-class","caller-class"]]"# + ); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index a5853d5b5..586079429 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -20505,6 +20505,205 @@ export const K = styled.div(base, cond && { color: 'blue' }, { margin: 1 });", } } + type StyledSpreadColor<'a> = ( + &'a str, + &'a str, + Option, + u8, + ); + + fn styled_conditional_spread_color_styles<'a>( + output: &'a ExtractOutput, + selector: Option<&css::style_selector::StyleSelector>, + ) -> Vec> { + let mut colors: Vec<_> = output + .styles + .iter() + .filter_map(|value| match value { + ExtractStyleValue::Static(style) + if style.property() == "color" && style.selector() == selector => + { + Some(( + style.value(), + style.property(), + style.selector().cloned(), + style.level(), + )) + } + ExtractStyleValue::Static(_) + | ExtractStyleValue::Dynamic(_) + | ExtractStyleValue::Typography(_) + | ExtractStyleValue::Css(_) + | ExtractStyleValue::Import(_) + | ExtractStyleValue::FontFace(_) + | ExtractStyleValue::Keyframes(_) => None, + }) + .collect(); + colors.sort_unstable(); + colors + } + + #[rstest] + #[case::both_branches( + "{ ...(on ? { color: 'red' } : { color: 'purple' }), color: 'orange' }", + None + )] + #[case::nested_hover( + "{ _hover: { ...(on ? { color: 'red' } : { color: 'purple' }), color: 'orange' } }", + Some("&:hover") + )] + #[case::independent_spreads( + "{ ...(on ? { color: 'red' } : { color: 'purple' }), color: 'blue', ...(other ? { color: 'green' } : { color: 'yellow' }), color: 'orange' }", + None + )] + #[case::unconditional_control("{ ...{ color: 'red' }, color: 'orange' }", None)] + #[case::missing_branches("{ ...(on ? { color: 'red' } : {}), color: 'orange' }", None)] + #[case::literal_computed_key("{ ...(on ? { ['color']: 'red' } : {}), color: 'orange' }", None)] + #[case::template_computed_key( + "{ ...(on ? { [`color`]: 'red' } : { color: 'purple' }), [`color`]: 'orange' }", + None + )] + #[case::literal_template_computed_key( + "{ ...(on ? { color: 'red' } : { color: 'purple' }), [`co${'lor'}`]: 'orange' }", + None + )] + #[case::logical_branch("{ ...(on && { color: 'red' }), color: 'orange' }", None)] + #[case::nested_branch( + "{ ...(on ? { ...(other ? { color: 'red' } : {}) } : {}), color: 'orange' }", + None + )] + #[case::responsive_replacement( + "{ ...(on ? { color: ['red', null, 'green'] } : { color: ['purple', 'blue'] }), color: 'orange' }", + None + )] + #[case::whole_selector( + "{ ...(on ? { _hover: { color: 'red', backgroundColor: 'yellow' } } : {}), _hover: { color: 'orange' } }", + Some("&:hover") + )] + #[case::syntax_wrappers( + "{ ...((on ? { color: 'red' } : {}) satisfies object), color: 'orange' }", + None + )] + #[serial] + fn test_styled_conditional_spread_final_scalar_wins( + #[case] rules: &str, + #[case] selector: Option<&str>, + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let code = format!( + "import {{ styled }} from '@devup-ui/react';\nconst Card = styled.div({rules});" + ); + let selector = + selector.map(|value| css::style_selector::StyleSelector::Selector(value.to_string())); + let output = extract( + "test.tsx", + &code, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + assert_eq!( + styled_conditional_spread_color_styles(&output, selector.as_ref()), + vec![("orange", "color", selector, 0)], + ); + } + + #[rstest] + #[case::per_file(false)] + #[case::single_css(true)] + #[serial] + fn test_styled_conditional_spread_exact_w38m(#[case] single_css: bool) { + reset_class_map(); + reset_file_map(); + let code = r"import {styled} from '@devup-ui/react';const __devupForwardRef=render=>render;const Card=styled('div',{_focus:{color:(trace.push('before'),true)?'green':'navy'},...((trace.push('spread'),true)?{color:'red',_active:{color:'yellow'}}:{color:'purple',_active:{color:'orange'}}),color:'purple',_visited:{color:(trace.push('between'),true)?'silver':'black'},...((trace.push('spread'),true)?{color:'red',_active:{color:'yellow'}}:{color:'purple',_active:{color:'orange'}}),color:'orange',_disabled:{color:(trace.push('after'),true)?'gray':'pink'},_hover:`color:blue`});trace.push('built');const a=Card({get stop(){trace.push('get');return true}},null);const b=Card({get stop(){trace.push('get');return false}},null); +"; + let output = extract( + "test.tsx", + code, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + assert_eq!( + styled_conditional_spread_color_styles(&output, None), + vec![("orange", "color", None, 0)], + ); + for (selector, values) in [ + ("&:active", vec!["orange", "yellow"]), + ("&:focus", vec!["green", "navy"]), + ("&:visited", vec!["black", "silver"]), + ("&:disabled", vec!["gray", "pink"]), + ("&:hover", vec!["blue"]), + ] { + let selector = css::style_selector::StyleSelector::Selector(selector.to_string()); + assert_eq!( + styled_conditional_spread_color_styles(&output, Some(&selector)), + values + .into_iter() + .map(|value| (value, "color", Some(selector.clone()), 0)) + .collect::>(), + ); + } + } + + #[rstest] + #[case::per_file(false)] + #[case::single_css(true)] + #[serial] + fn test_styled_conditional_spread_runtime_class_callback_rejected(#[case] single_css: bool) { + reset_class_map(); + reset_file_map(); + let code = "import { styled } from '@devup-ui/react';\nstyled.div(p => { return p.cls; });"; + let result = extract( + "test.tsx", + code, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + assert_eq!( + result.err().map(|error| error.to_string()), + Some("test.tsx:2:1: Cannot compose `(p) => { return p.cls; }` at build time: each style must be a rule object, a class, or a condition choosing between them".to_string()), + ); + } + + #[rstest] + #[case::member("styled.div")] + #[case::curried("styled('div')")] + #[case::two_argument("styled")] + #[serial] + fn test_styled_conditional_spread_call_forms( + #[case] factory: &str, + #[values(false, true)] single_css: bool, + ) { + reset_class_map(); + reset_file_map(); + let tag = if factory == "styled" { "'div', " } else { "" }; + let code = format!( + "import {{ styled }} from '@devup-ui/react'; const Card = {factory}({tag}{{ ...(on ? {{ color: 'red' }} : {{ color: 'purple' }}), color: 'orange' }});" + ); + let output = extract( + "test.tsx", + &code, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + assert_eq!( + styled_conditional_spread_color_styles(&output, None), + vec![("orange", "color", None, 0)] + ); + } + #[test] #[serial] fn test_devup_props_typescript_wrappers() {