diff --git a/.changepacks/changepack_log_local_style_objects.json b/.changepacks/changepack_log_local_style_objects.json new file mode 100644 index 000000000..88d69c9e5 --- /dev/null +++ b/.changepacks/changepack_log_local_style_objects.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A style object declared with const inside a function and passed only to styling APIs now compiles when its entries are written out, including Emotion, styled-components and vanilla-extract aliases. Successful member, primitive and constant-condition folding accounts for erased semantic references, so mutation or extra-use paths keep the existing located refusal while safe style-only member reads compile. css() and styled() compose known conditional rule arrays with the controller read once. Whole-rule props callbacks preserve their original invocation scope, destructuring, defaults, rest and authored argument evaluation order, including covered reads and empty/static-only results; fixed dynamic values become CSS variables without a runtime rule-object interpreter. Nonzero numbers remain px lengths except for unitless properties, and proven theme paths remain static CSS variables. Implicit actual-props theme reads through own arguments or a first rest formal retain located errors with correct catch, loop, switch and var binding ownership. Named whole-rule callbacks that refer to their own binding, including metadata reads, now give a located error naming the callback and explaining that recursion is not compiled; a same-named type parameter cannot hide that value self-reference, while type-only names and actual value shadows still compile. Repeated preparation checks, traversal and error buffering are consolidated without changing output or existing diagnostics. Opaque calls in Emotion ClassNames cx remain located errors until their result is proven unable to carry Devup style classes; only that allowance is deferred. Other located errors remain: attrs chained after styles, callbacks without one immediate explicit rule object, and local style objects also read elsewhere or holding unprovable style structure. No styling runtime or helper is added.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_w62m_745_repair.json b/.changepacks/changepack_log_w62m_745_repair.json new file mode 100644 index 000000000..ae62ea361 --- /dev/null +++ b/.changepacks/changepack_log_w62m_745_repair.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Preserve extractor callback, theme-read and rule-array behavior while removing redundant preparation and unreachable analysis paths. Add Linux-coverable regression cases without coverage exclusions. No new build errors.", + "date": "2026-10-21T00:00:00.000Z" +} diff --git a/libs/extractor/src/consumed_references.rs b/libs/extractor/src/consumed_references.rs new file mode 100644 index 000000000..2be0dbea3 --- /dev/null +++ b/libs/extractor/src/consumed_references.rs @@ -0,0 +1,81 @@ +//! Semantic references removed by successful constant inlining. + +use oxc_ast::ast::{Expression, IdentifierReference}; +use oxc_ast_visit::Visit; +use oxc_semantic::Scoping; +use oxc_syntax::{reference::ReferenceId, symbol::SymbolId}; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Counts erased references once, even when a retained AST node is revisited. +pub(super) struct ConsumedReferences<'s> { + scoping: &'s Scoping, + seen: FxHashSet, + by_symbol: FxHashMap, +} + +impl<'s> ConsumedReferences<'s> { + pub(super) fn new(scoping: &'s Scoping) -> Self { + Self { + scoping, + seen: FxHashSet::default(), + by_symbol: FxHashMap::default(), + } + } + + pub(super) fn count(&self, symbol: SymbolId) -> usize { + self.by_symbol.get(&symbol).copied().unwrap_or(0) + } + + /// Call only for a subtree about to be erased, never for a folding probe. + pub(super) fn consume(&mut self, expression: &Expression<'_>) { + self.visit_expression(expression); + } +} + +impl<'a> Visit<'a> for ConsumedReferences<'_> { + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + && self.seen.insert(reference) + { + *self.by_symbol.entry(symbol).or_default() += 1; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use oxc_ast::ast::Statement; + + #[test] + fn references_count_once_when_a_computed_subtree_is_revisited() { + // Given: two distinct references to one binding in a computed member. + let allocator = oxc_allocator::Allocator::default(); + let program = oxc_parser::Parser::new( + &allocator, + "const holder = {}; holder[holder.key];", + oxc_span::SourceType::ts(), + ) + .parse() + .program; + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let symbol = scoping + .symbol_ids() + .find(|symbol| scoping.symbol_name(*symbol) == "holder") + .unwrap_or_else(|| panic!("fixture must declare holder")); + let Some(Statement::ExpressionStatement(statement)) = program.body.last() else { + panic!("fixture must end with the computed read"); + }; + let mut references = ConsumedReferences::new(&scoping); + // When: the same semantic subtree is accounted more than once. + for _ in 0..2 { + references.consume(&statement.expression); + } + // Then: distinct operands count, repeated visits do not. + assert_eq!(references.count(symbol), 2); + } +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 24d43997f..50bb437f3 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -25,6 +25,14 @@ use oxc_ast::ast::Expression; use oxc_ast::ast::{TemplateElement, TemplateLiteral}; use oxc_ast::builder::AstBuilder; +#[path = "implicit_props_theme.rs"] +mod implicit_props_theme; + +#[path = "theme_callback.rs"] +mod theme_callback; + +pub(crate) use implicit_props_theme::reads as reads_implicit_props_theme; + use crate::extract_style::{ extract_dynamic_style::ExtractDynamicStyle, extract_static_style::ExtractStaticStyle, extract_style_value::ExtractStyleValue, @@ -57,24 +65,43 @@ pub fn rm_last_semi_colon(code: &str) -> &str { /// style static; left alone the arrow would be called with the element's props, /// which carry no `theme`, and silently evaluate to `undefined`. pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { - let Expression::ArrowFunctionExpression(arrow) = expr else { - return None; + use oxc_ast_visit::Visit; + struct Defaults(bool); + impl<'a> Visit<'a> for Defaults { + fn visit_assignment_pattern(&mut self, _: &oxc_ast::ast::AssignmentPattern<'a>) { + self.0 = true; + } + } + let (params, body) = match expr { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + (&arrow.params, arrow.body.as_expression()?) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => ( + &function.params, + crate::css_prop::returned(function.body.as_deref()?)?, + ), + _ => return None, }; - let [param] = arrow.params.items.as_slice() else { + let [param] = params.items.as_slice() else { return None; }; - let root = match ¶m.pattern { - BindingPattern::BindingIdentifier(ident) => ThemeRoot::Props(ident.name.as_str()), - BindingPattern::ObjectPattern(pattern) => pattern - .properties - .iter() - .any(|p| get_str_by_property_key(&p.key).is_some_and(|key| key == "theme")) - .then_some(ThemeRoot::Theme)?, - _ => return None, - }; + let mut defaults = Defaults(false); + defaults.visit_binding_pattern(¶m.pattern); + if params.rest.is_some() || param.initializer.is_some() || defaults.0 { + return None; + } + theme_leaf_reference(params, body) +} + +/// Map a leaf without erasing its enclosing callback's parameter evaluation. +pub(crate) fn theme_leaf_reference( + params: &oxc_ast::ast::FormalParameters<'_>, + body: &Expression<'_>, +) -> Option { + let root = theme_root(¶ms.items.first()?.pattern)?; let mut path: Vec> = Vec::new(); - let mut cursor = arrow.body.as_expression()?; + let mut cursor = body; loop { match cursor { Expression::StaticMemberExpression(member) => { @@ -88,8 +115,7 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { } Expression::Identifier(ident) => { let matches_root = match root { - ThemeRoot::Props(name) => ident.name.as_str() == name, - ThemeRoot::Theme => ident.name.as_str() == "theme", + ThemeRoot::Props(name) | ThemeRoot::Theme(name) => ident.name.as_str() == name, }; if !matches_root { return None; @@ -116,7 +142,7 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { use oxc_ast_visit::{Visit, walk}; struct ThemeReads<'n> { - props: Option<&'n str>, + root: ThemeRoot<'n>, found: bool, } impl<'a> Visit<'a> for ThemeReads<'_> { @@ -125,48 +151,61 @@ pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { it: &oxc_ast::ast::StaticMemberExpression<'a>, ) { if it.property.name == "theme" - && matches!(&it.object, Expression::Identifier(object) if Some(object.name.as_str()) == self.props) + && matches!(self.root, ThemeRoot::Props(name) + if matches!(&it.object, Expression::Identifier(object) if object.name == name)) { self.found = true; } walk::walk_static_member_expression(self, it); } + fn visit_computed_member_expression( + &mut self, + it: &oxc_ast::ast::ComputedMemberExpression<'a>, + ) { + if get_string_by_literal_expression(&it.expression).is_some_and(|key| key == "theme") + && matches!(self.root, ThemeRoot::Props(name) + if matches!(&it.object, Expression::Identifier(object) if object.name == name)) + { + self.found = true; + } + walk::walk_computed_member_expression(self, it); + } fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { - if self.props.is_none() && it.name == "theme" { + if matches!(self.root, ThemeRoot::Theme(name) if it.name == name) { self.found = true; } } } - let Expression::ArrowFunctionExpression(arrow) = expr else { + if reads_implicit_props_theme(expr) { + return true; + } + let Some(theme_callback::Callback { params, body }) = theme_callback::Callback::read(expr) + else { return false; }; - let [param] = arrow.params.items.as_slice() else { + let Some(param) = params.items.first() else { return false; }; - let props = match ¶m.pattern { - BindingPattern::BindingIdentifier(ident) => Some(ident.name.as_str()), - BindingPattern::ObjectPattern(pattern) - if pattern - .properties - .iter() - .any(|p| get_str_by_property_key(&p.key).is_some_and(|key| key == "theme")) => - { - None - } - _ => return false, + let Some(root) = theme_root(¶m.pattern) else { + return false; }; if theme_var_reference(expr).is_some() { return false; } - let mut reads = ThemeReads { - props, - found: false, - }; - match &arrow.body { - oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) => reads.visit_function_body(body), - body => { - if let Some(expression) = body.as_expression() { - reads.visit_expression(expression); + let mut reads = ThemeReads { root, found: false }; + reads.visit_formal_parameters(params); + match body { + theme_callback::Body::Arrow(body) => match body { + oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) => reads.visit_function_body(body), + body => { + if let Some(expression) = body.as_expression() { + reads.visit_expression(expression); + } + } + }, + theme_callback::Body::Function(body) => { + if let Some(body) = body { + reads.visit_function_body(body); } } } @@ -185,9 +224,32 @@ pub(crate) fn unmapped_theme_error(api: &str, expr: &Expression<'_>) -> (u32, St ) } +#[derive(Clone, Copy)] enum ThemeRoot<'a> { Props(&'a str), - Theme, + Theme(&'a str), +} + +/// The props parameter or the local binding destructured from its theme. +fn theme_root<'p>(pattern: &'p BindingPattern<'_>) -> Option> { + match pattern { + BindingPattern::BindingIdentifier(identifier) => { + Some(ThemeRoot::Props(identifier.name.as_str())) + } + BindingPattern::ObjectPattern(object) => object.properties.iter().find_map(|property| { + let mut value = &property.value; + while let BindingPattern::AssignmentPattern(default) = value { + value = &default.left; + } + let BindingPattern::BindingIdentifier(identifier) = value else { + return None; + }; + get_str_by_property_key(&property.key) + .is_some_and(|key| key == "theme") + .then_some(ThemeRoot::Theme(identifier.name.as_str())) + }), + _ => None, + } } /// Convert a dynamic template-literal expression into identifier code, diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index fe1af3863..8e0d2e921 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -639,20 +639,21 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( } // `styled.div({ color: (p) => p.theme.brand })` — the object spelling of // the template interpolation, resolved to the same build-time `var()`. - Expression::ArrowFunctionExpression(_) => match (name, theme_var_reference(expression)) - { - (Some(name), Some(reference)) => ExtractResult { - styles: create_static_styles( - name, - &reference, - &[level], - selector, - ThemeTokenResolution::default(), - ), - ..ExtractResult::default() - }, - _ => unreadable(expression), - }, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => { + match (name, theme_var_reference(expression)) { + (Some(name), Some(reference)) => ExtractResult { + styles: create_static_styles( + name, + &reference, + &[level], + selector, + ThemeTokenResolution::default(), + ), + ..ExtractResult::default() + }, + _ => unreadable(expression), + } + } // Each variant is kept on its own line so per-line coverage // tools (tarpaulin on CI) can attribute the hit to the exact // pattern being exercised. @@ -1117,12 +1118,7 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( Some(_) if matches!(expression, Expression::NullLiteral(_)) => { ExtractResult::default() } - Some(_) - if matches!( - expression, - Expression::FunctionExpression(_) | Expression::ClassExpression(_) - ) => - { + Some(_) if matches!(expression, Expression::ClassExpression(_)) => { unreadable(expression) } // A sequence keeps its parentheses, as its code is written into diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 853908060..6cd0127a4 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -15,16 +15,15 @@ use crate::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, styled_record::prepare, - styled_record_evaluation::{Evaluations, bind_render, visit_style_expressions}, + styled_record_evaluation::{Evaluations, bind_render_body, visit_style_expressions}, }, gen_class_name::{gen_class_names, merge_expression_for_class_name}, gen_style::gen_styles, styled_reads::{Forward, Reads, withheld}, utils::{ - STYLE_OBJECT, StyleArguments, build_time_error, call_with_values, expression_to_code, - merge_object_expressions, readable_code, reads_directly, style_arguments, - uncomposable_error, unplaced_error, unreadable_styles, unwrap_syntax_only, - unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, + STYLE_OBJECT, build_time_error, call_with_values, expression_to_code, + merge_object_expressions, readable_code, unplaced_error, unreadable_styles, + unwrap_syntax_only, unwrap_syntax_only_mut, wrap_array_filter, wrap_direct_call, }, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator}; @@ -41,6 +40,16 @@ use oxc_parser::{Parser, ParserReturn}; use oxc_span::{GetSpan, SPAN, SourceType}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; +#[path = "styled_props.rs"] +mod props; + +#[path = "styled_arguments.rs"] +mod arguments; + +#[cfg(test)] +#[path = "styled_render_body_tests.rs"] +mod render_body_tests; + /// The binding a styled component reads the component it renders through, when /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; @@ -217,6 +226,9 @@ impl<'a> StyledDefinition<'a> { for (_, value) in &mut inherited.bindings { rename.visit_expression(value); } + for class in &mut inherited.classes { + rename.visit_expression(class); + } for style in &mut inherited.styles { visit_style_expressions(builder, style, &mut |value| rename.visit_expression(value)); } @@ -797,7 +809,7 @@ pub fn extract_style_from_styled<'a>( expression: &mut Expression<'a>, naming: Naming<'_>, imports: Kinds<'_>, - modifiers: (&[Expression<'a>], Option), + modifiers: (&[Expression<'a>], Option, &[u32]), inherited: Option<&StyledDefinition<'a>>, context: StyledContext<'_>, ) -> StyledExtraction<'a> { @@ -805,7 +817,7 @@ pub fn extract_style_from_styled<'a>( split_filename, markers, } = naming; - let (attrs, forward) = modifiers; + let (attrs, forward, refused_array_controllers) = modifiers; 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(); @@ -816,28 +828,22 @@ pub fn extract_style_from_styled<'a>( } let mut composed_classes = Vec::new(); let mut errors = Vec::new(); + let mut captured_bindings = Vec::new(); if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { - match style_arguments(ast_builder, &call.arguments) { - Some(StyleArguments { classes, rules }) => { - call.arguments = - oxc_allocator::Vec::from_array_in([Argument::from(rules)], ast_builder); - composed_classes = classes; - } - None if !reads_directly(&call.arguments) => { - errors.push((call.span.start, uncomposable_error(&call.arguments))); - call.arguments = oxc_allocator::Vec::from_array_in( - [Argument::from(Expression::new_object_expression( - SPAN, - oxc_allocator::Vec::new_in(ast_builder), - ast_builder, - ))], - ast_builder, - ); - } - None => {} - } + let prepared = arguments::prepare( + ast_builder, + call, + arguments::Context { + scope: &mut original_scope, + reads: &mut reads, + refused: refused_array_controllers, + }, + ); + composed_classes = prepared.classes; + captured_bindings = prepared.bindings; + errors.extend(prepared.errors); } let (result, new_expr, definition) = if let Expression::TaggedTemplateExpression(tag) = expression @@ -1012,6 +1018,7 @@ pub fn extract_style_from_styled<'a>( .map(|(name, value)| (name.clone(), value.clone_in(ast_builder.allocator()))) .collect() }); + bindings.extend(captured_bindings); bindings.extend(match prepared { Some(prepared) => { call.arguments[style_index] = Argument::from(prepared.rules); @@ -1997,7 +2004,7 @@ fn create_styled_component<'a>( None::>>, params, None::>>, - bind_render(ast_builder, bindings, body).into(), + bind_render_body(ast_builder, bindings, body), ast_builder, ) } diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index e661a4e3c..7cfe8026b 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,13 +9,15 @@ 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; +pub(super) mod rule_array_evaluation; pub(super) mod styled_capability; pub(super) mod styled_css_slot; pub(super) mod styled_jsx; mod styled_record; -mod styled_record_evaluation; +pub(super) mod styled_record_evaluation; #[cfg(test)] mod styled_record_tests; +pub(super) mod styled_rule_arrays; mod styled_transient; /** diff --git a/libs/extractor/src/extractor/rule_array_evaluation.rs b/libs/extractor/src/extractor/rule_array_evaluation.rs new file mode 100644 index 000000000..5badabd41 --- /dev/null +++ b/libs/extractor/src/extractor/rule_array_evaluation.rs @@ -0,0 +1,182 @@ +//! Capture authored evaluations before rule-array composition duplicates controllers. + +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{Expression, ObjectPropertyKind, PropertyKind}; +use oxc_span::SPAN; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator}; + +use super::styled_record_evaluation::Evaluations; +use crate::utils::{ + Suspends, get_string_by_literal_expression, unwrap_syntax_only, unwrap_syntax_only_mut, +}; + +/// Arrays here compose rules, rather than selecting responsive property values. +pub(crate) fn composes_array_under_condition(expression: &Expression<'_>) -> bool { + let sides = match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => { + return array.elements.iter().any(|element| { + element + .as_expression() + .is_some_and(composes_array_under_condition) + }); + } + Expression::ConditionalExpression(choice) => [&choice.consequent, &choice.alternate], + Expression::LogicalExpression(logical) => [&logical.left, &logical.right], + _ => return false, + }; + sides.into_iter().any(|side| { + matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) + || composes_array_under_condition(side) + }) +} + +#[cfg(test)] +#[path = "rule_array_evaluation_tests.rs"] +mod tests; + +/// The caller first checks the original composition, so captures cannot turn a +/// refused call, spread or controller into an accepted identifier. +pub(crate) fn prepare<'a>( + reads: &mut Evaluations<'_, 'a>, + source: &Expression<'a>, + known: impl Fn(&Expression<'a>) -> bool, +) -> Option> { + let mut suspends = Suspends::default(); + oxc_ast_visit::Visit::visit_expression(&mut suspends, source); + if suspends.found { + return None; + } + let mut result = source.clone_in_with_semantic_ids(reads.builder.allocator()); + let guard = reads.boolean(true); + Preparation { reads, known }.value(&mut result, &guard)?; + Some(result) +} + +struct Preparation<'r, 'b, 'a, K> { + reads: &'r mut Evaluations<'b, 'a>, + known: K, +} + +impl<'a, K: Fn(&Expression<'a>) -> bool> Preparation<'_, '_, 'a, K> { + fn capture(&mut self, value: &mut Expression<'a>, guard: &Expression<'a>) { + if get_string_by_literal_expression(value).is_none() + && !matches!(unwrap_syntax_only(value), Expression::NullLiteral(_)) + && !matches!(unwrap_syntax_only(value), Expression::Identifier(identifier) + if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity")) + && !(self.known)(unwrap_syntax_only(value)) + { + *value = self.reads.capture(value, guard); + } + } + + fn value(&mut self, value: &mut Expression<'a>, guard: &Expression<'a>) -> Option<()> { + if (self.known)(unwrap_syntax_only(value)) + || get_string_by_literal_expression(value).is_some() + { + return Some(()); + } + let builder = self.reads.builder; + match unwrap_syntax_only_mut(value) { + Expression::ConditionalExpression(choice) => { + self.capture(&mut choice.test, guard); + let yes = self + .reads + .choose(guard, &choice.test, &self.reads.boolean(false)); + let no = self.reads.choose( + guard, + &self.reads.choose( + &choice.test, + &self.reads.boolean(false), + &self.reads.boolean(true), + ), + &self.reads.boolean(false), + ); + self.value(&mut choice.consequent, &yes)?; + self.value(&mut choice.alternate, &no)?; + } + Expression::LogicalExpression(logical) => { + // A rule object/array is truthy, but evaluating its authored + // leaves still precedes everything after this part. + let takes_right = if matches!( + unwrap_syntax_only(&logical.left), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ) { + self.value(&mut logical.left, guard)?; + self.reads.boolean(logical.operator == LogicalOperator::And) + } else { + self.capture(&mut logical.left, guard); + match logical.operator { + LogicalOperator::And => logical.left.clone_in(builder.allocator()), + LogicalOperator::Or => self.reads.choose( + &logical.left, + &self.reads.boolean(false), + &self.reads.boolean(true), + ), + LogicalOperator::Coalesce => Expression::new_binary_expression( + SPAN, + logical.left.clone_in(builder.allocator()), + BinaryOperator::Equality, + Expression::new_null_literal(SPAN, builder), + builder, + ), + } + }; + let right = self + .reads + .choose(guard, &takes_right, &self.reads.boolean(false)); + self.value(&mut logical.right, &right)?; + } + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + match element { + oxc_ast::ast::ArrayExpressionElement::SpreadElement(spread) => { + self.value(&mut spread.argument, guard)?; + } + element => { + if let Some(value) = element.as_expression_mut() { + self.value(value, guard)?; + } + } + } + } + } + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if property.method || property.kind != PropertyKind::Init { + return None; + } + // Keep authored keys and literal lookup tables intact: + // the extractor still owns their policy and atom choices. + self.value(&mut property.value, guard)?; + property.shorthand = false; + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.value(&mut spread.argument, guard)?; + } + } + } + } + Expression::ComputedMemberExpression(member) + if matches!( + unwrap_syntax_only(&member.object), + Expression::ObjectExpression(_) | Expression::ArrayExpression(_) + ) => + { + self.value(&mut member.object, guard)?; + self.capture(&mut member.expression, guard); + } + Expression::NullLiteral(_) + | Expression::BigIntLiteral(_) + | Expression::RegExpLiteral(_) + | Expression::FunctionExpression(_) + | Expression::ArrowFunctionExpression(_) + | Expression::ClassExpression(_) => {} + Expression::Identifier(identifier) + if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") => {} + _ => *value = self.reads.capture(value, guard), + } + Some(()) + } +} diff --git a/libs/extractor/src/extractor/rule_array_evaluation_tests.rs b/libs/extractor/src/extractor/rule_array_evaluation_tests.rs new file mode 100644 index 000000000..c33ac3e3f --- /dev/null +++ b/libs/extractor/src/extractor/rule_array_evaluation_tests.rs @@ -0,0 +1,124 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::super::styled_record_evaluation::{Evaluations, bind_render}; +use crate::utils::readable_code; + +#[rstest] +#[case::object_and( + "({x:read('left',1)}) && [{x:read('right',2)}]", + r#"[[{"x":2}],["left","right"]]"# +)] +#[case::array_or( + "[{x:read('left',1)}] || [{x:read('right',2)}]", + r#"[[{"x":1}],["left"]]"# +)] +#[case::object_coalesce( + "({x:read('left',1)}) ?? [{x:read('right',2)}]", + r#"[{"x":1},["left"]]"# +)] +#[case::null_coalesce( + "read('left',null) ?? [{x:read('right',2)}]", + r#"[[{"x":2}],["left","right"]]"# +)] +#[case::zero_coalesce("read('left',0) ?? [{x:read('right',2)}]", r#"[0,["left"]]"#)] +#[case::false_coalesce("read('left',false) ?? [{x:read('right',2)}]", r#"[false,["left"]]"#)] +#[case::spread( + "[...[{x:read('spread',3)}],{x:read('tail',4)}]", + r#"[[{"x":3},{"x":4}],["spread","tail"]]"# +)] +fn repair_745_rule_array_captures_preserve_evaluation( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: authored evaluations with observable order and short circuiting. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let expression = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut reads = Evaluations::new(&builder, source.to_string()); + + // When: execute the prepared expression through the production binder. + let prepared = super::prepare(&mut reads, &expression, |_| false) + .unwrap_or_else(|| panic!("capturable fixture")); + let render = bind_render(&builder, reads.bindings, prepared); + let script = format!( + "const trace=[];const read=(name,value)=>(trace.push(name),value);JSON.stringify([{},trace]);", + readable_code(&render) + ); + let observed = Context::default() + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("JSON observation")) + .to_std_string_escaped(); + + // Then: the chosen value and each once-only evaluation retain authored order. + assert_eq!(observed, expected); +} + +#[rstest] +#[case::await_value("[{color:await tone}]")] +#[case::yield_value("[{color:yield tone}]")] +#[case::getter("[{get color(){return 'red'}}]")] +#[case::setter("[{set color(value){}}]")] +#[case::method("[{color(){return 'red'}}]")] +fn repair_745_rule_array_refuses_unpreparable_values(#[case] source: &str) { + // Given: a parsed suspension or non-data property that cannot be captured. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = if source.contains("yield") { + format!("function*(){{return {source};}}") + } else { + source.to_string() + }; + let expression = Parser::new(&allocator, &source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let expression = match &expression { + oxc_ast::ast::Expression::FunctionExpression(function) => crate::css_prop::returned( + function + .body + .as_deref() + .unwrap_or_else(|| panic!("generator body")), + ) + .unwrap_or_else(|| panic!("returned fixture")), + expression => expression, + }; + let mut reads = Evaluations::new(&builder, source.clone()); + + // When: attempt preparation at the capture boundary. + let prepared = super::prepare(&mut reads, expression, |_| false); + + // Then: the original refusal remains explicit, rather than capturing invalid syntax. + assert!(prepared.is_none()); +} + +#[rstest] +#[case("read() ? [] : []")] +#[case("(read() ? [] : []) || (other && [])")] +fn repair_745_refused_controllers_keep_authored_spans(#[case] source: &str) { + // Given: impure controllers nested on either side of a logical composition. + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut refused = Vec::new(); + + // When: collect controller refusals before any rewriting. + super::super::styled_rule_arrays::refused_controllers(&expression, &mut refused); + + // Then: the call-controlled choice remains refused at its authored start. + let start = u32::try_from( + source + .find("read()") + .unwrap_or_else(|| panic!("controller fixture")), + ) + .unwrap_or_else(|error| panic!("{error}")); + assert!(refused.contains(&start), "{refused:?}"); +} diff --git a/libs/extractor/src/extractor/styled_arguments.rs b/libs/extractor/src/extractor/styled_arguments.rs new file mode 100644 index 000000000..9582a5e2e --- /dev/null +++ b/libs/extractor/src/extractor/styled_arguments.rs @@ -0,0 +1,163 @@ +//! Admission precedes one authored-order schedule shared by callbacks and rule arrays. + +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{Argument, CallExpression, Expression}, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +use super::props; +use crate::{ + extractor::{ + rule_array_evaluation, styled_record_evaluation::Evaluations, + styled_rule_arrays::distribute_conditions, + }, + styled_reads::Reads, + utils::{StyleArguments, readable_code, reads_directly, style_arguments, uncomposable_error}, +}; + +pub(super) struct Context<'s, 'a> { + pub scope: &'s mut Expression<'a>, + pub reads: &'s mut Reads, + pub refused: &'s [u32], +} + +pub(super) struct Prepared<'a> { + pub classes: Vec>, + pub bindings: Vec<(String, Expression<'a>)>, + pub errors: Vec<(u32, String)>, +} + +fn distribute_arguments<'a>( + builder: &AstBuilder<'a>, + arguments: &mut [Argument<'a>], + refused: &[u32], +) { + for argument in arguments { + if let Some(part) = argument.as_expression_mut() { + distribute_conditions(builder, part, refused); + } + } +} + +pub(super) fn prepare<'a>( + builder: &AstBuilder<'a>, + call: &mut CallExpression<'a>, + context: Context<'_, 'a>, +) -> Prepared<'a> { + let Context { + scope, + reads, + refused, + } = context; + let arguments = &mut call.arguments; + let mut result = Prepared { + classes: Vec::new(), + bindings: Vec::new(), + errors: Vec::new(), + }; + let mut evaluations = Evaluations::new(builder, readable_code(scope)); + let mut plans = Vec::with_capacity(arguments.len()); + for argument in &mut *arguments { + let plan = if let Some(rules) = argument.as_expression_mut() { + reads.read_in(rules); + match props::lower(&mut evaluations, rules) { + Ok(plan) => plan, + Err(error) => { + result.errors.push(error); + *rules = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(builder), + builder, + ); + None + } + } + } else { + None + }; + plans.push(plan); + } + let mut distributed = arguments.clone_in_with_semantic_ids(builder.allocator()); + distribute_arguments(builder, &mut distributed, refused); + let admitted = reads_directly(&distributed) || style_arguments(builder, &distributed).is_some(); + let callbacks = plans.iter().any(Option::is_some); + let capture = callbacks + || arguments.iter().any(|argument| { + argument + .as_expression() + .is_some_and(rule_array_evaluation::composes_array_under_condition) + }); + if admitted && capture { + let mut prepared = arguments.clone_in_with_semantic_ids(builder.allocator()); + let mut complete = true; + for (argument, plan) in prepared.iter_mut().zip(plans) { + if let Some(plan) = plan { + evaluations.bindings.push((plan.name, plan.value)); + } else { + let value = match &*argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + let start = evaluations.bindings.len(); + if let Some(value_read) = + rule_array_evaluation::prepare(&mut evaluations, value, |_| false) + { + if callbacks && let Argument::SpreadElement(spread) = argument { + spread.argument = value_read; + } else { + *argument = Argument::from(value_read); + } + } else { + evaluations.bindings.truncate(start); + if !callbacks { + complete = false; + break; + } + } + } + } + if complete { + *arguments = prepared; + let names = evaluations.bindings.iter().map(|(name, _)| { + Expression::new_identifier( + SPAN, + oxc_ast::ast::Ident::from_in(name.as_str(), builder.allocator()), + builder, + ) + .into() + }); + let mut reserved = oxc_allocator::Vec::from_iter_in(names, builder); + reserved.push(scope.clone_in(builder.allocator()).into()); + *scope = Expression::new_array_expression(SPAN, reserved, builder); + result.bindings = evaluations.bindings; + } + } + distribute_arguments(builder, arguments, refused); + match style_arguments(builder, arguments) { + Some(StyleArguments { classes, rules }) => { + *arguments = oxc_allocator::Vec::from_array_in([Argument::from(rules)], builder); + result.classes = classes; + } + None if !reads_directly(arguments) => { + result + .errors + .push((call.span.start, uncomposable_error(arguments))); + *arguments = oxc_allocator::Vec::from_array_in( + [Argument::from(Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(builder), + builder, + ))], + builder, + ); + } + None => {} + } + result +} + +#[cfg(test)] +#[path = "styled_arguments_tests.rs"] +mod tests; diff --git a/libs/extractor/src/extractor/styled_arguments_tests.rs b/libs/extractor/src/extractor/styled_arguments_tests.rs new file mode 100644 index 000000000..66410411c --- /dev/null +++ b/libs/extractor/src/extractor/styled_arguments_tests.rs @@ -0,0 +1,137 @@ +use boa_engine::{Context as BoaContext, Source}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::*; +use crate::extractor::styled_record_evaluation::bind_render; + +#[rstest] +#[case::callback_spread( + "styled(...[{color:read('spread','red')}],p=>({margin:p.m}))", + r#"[{"color":"red","margin":"2px"},["spread","props"]]"# +)] +#[case::array_spread( + "styled(...[{color:read('spread','red')}],on?[{margin:read('margin',2)}]:[])", + r#"[{"color":"red","margin":2},["spread","margin"]]"# +)] +fn repair_745_styled_arguments_keep_spread_capture_order( + #[case] source: &str, + #[case] expected: &str, +) { + // Given: spread rules preceding a callback or a conditional rule array. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut scope = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut expression = scope.clone_in(&allocator); + assert!(expression.is_call_expression()); + if let Expression::CallExpression(call) = &mut expression { + let mut reads = Reads::default(); + + // When: prepare real styled arguments and execute their ordered bindings. + let prepared = prepare( + &builder, + call, + Context { + scope: &mut scope, + reads: &mut reads, + refused: &[], + }, + ); + assert_eq!(prepared.errors, vec![]); + let rules = call.arguments[0].to_expression().clone_in(&allocator); + let render = bind_render(&builder, prepared.bindings, rules); + let script = format!( + "const trace=[];const on=true;const rest={{get m(){{trace.push('props');return 2}}}};const read=(name,value)=>(trace.push(name),value);JSON.stringify([{},trace]);", + readable_code(&render) + ); + let observed = BoaContext::default() + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}")) + .as_string() + .unwrap_or_else(|| panic!("JSON observation")) + .to_std_string_escaped(); + + // Then: spread reads precede later argument reads, each once, with the same rules. + assert_eq!(observed, expected); + } +} + +#[rstest] +#[case::callback("styled({color:await tone},p=>({margin:p.m}))", 1)] +#[case::array("styled(on?[{color:await tone}]:[],{margin:1})", 0)] +fn repair_745_styled_arguments_keep_suspending_fallback( + #[case] source: &str, + #[case] captures: usize, +) { + // Given: a suspending sibling that preparation must leave at its original seam. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut scope = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut expression = scope.clone_in(&allocator); + assert!(expression.is_call_expression()); + if let Expression::CallExpression(call) = &mut expression { + let mut reads = Reads::default(); + + // When: prepare the admitted call without hoisting the suspension. + let prepared = prepare( + &builder, + call, + Context { + scope: &mut scope, + reads: &mut reads, + refused: &[], + }, + ); + + // Then: successful callback captures survive, but the awaiting value stays authored. + assert_eq!(prepared.errors, vec![]); + assert_eq!(prepared.bindings.len(), captures); + assert!(readable_code(call.arguments[0].to_expression()).contains("await tone")); + } +} + +#[test] +fn repair_745_nested_callback_keeps_unmapped_theme_error() { + // Given: a nested property callback with its own, different props root. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let source = "styled(p=>({color:q=>q.theme.colors[key]}))"; + let mut scope = Parser::new(&allocator, source, SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + let mut expression = scope.clone_in(&allocator); + assert!(expression.is_call_expression()); + if let Expression::CallExpression(call) = &mut expression { + let mut reads = Reads::default(); + + // When: lower the whole-rule callback through the ordinary argument seam. + let prepared = prepare( + &builder, + call, + Context { + scope: &mut scope, + reads: &mut reads, + refused: &[], + }, + ); + + // Then: the inner callback's unknown theme access retains its located guard. + assert_eq!(prepared.errors.len(), 1); + assert!(prepared.errors[0].1.contains("a theme read must be a path")); + assert_eq!( + prepared.errors[0].0, + u32::try_from( + source + .find("q=>") + .unwrap_or_else(|| panic!("inner callback fixture")) + ) + .unwrap_or_else(|error| panic!("{error}")) + ); + } +} diff --git a/libs/extractor/src/extractor/styled_props.rs b/libs/extractor/src/extractor/styled_props.rs new file mode 100644 index 000000000..f8e96c9e1 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props.rs @@ -0,0 +1,129 @@ +//! Fixed rule keys with one original props-callback invocation per render. + +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, ObjectExpression, ObjectPropertyKind}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, SPAN}; + +use super::{super::styled_record_evaluation::Evaluations, identifier}; +use crate::{ + css_utils::{ + reads_implicit_props_theme, reads_unmapped_theme, theme_var_reference, unmapped_theme_error, + }, + utils::{ + build_time_error, readable_code, unwrap_syntax_only, unwrap_syntax_only_mut, + wrap_direct_call, + }, +}; + +#[path = "styled_props_values.rs"] +mod values; + +#[path = "styled_props_leaves.rs"] +mod leaves; + +#[path = "styled_props_self_reference.rs"] +mod self_reference; + +const PROPS_FUNCTION: &str = "a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`"; + +pub(super) struct Invocation<'a> { + pub name: String, + pub value: Expression<'a>, +} + +enum PropsRulesError { + Rules, + Theme((u32, String)), +} + +/// Plan a supported callback without scheduling it ahead of earlier arguments. +pub(super) fn lower<'a>( + evaluations: &mut Evaluations<'_, 'a>, + style: &mut Expression<'a>, +) -> Result>, (u32, String)> { + let builder = evaluations.builder; + if !matches!( + unwrap_syntax_only(style), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(style) { + call_with_props(builder, object)?; + } + return Ok(None); + } + let mut function = style.clone_in_with_semantic_ids(builder.allocator()); + let invalid = || { + ( + style.span().start, + build_time_error("styled", &readable_code(style), PROPS_FUNCTION), + ) + }; + let (params, returned) = crate::css_prop::render_function(&mut function).ok_or_else(invalid)?; + let Expression::ObjectExpression(object) = unwrap_syntax_only(returned) else { + // Keep the original composition refusal for non-object callbacks. + return Ok(None); + }; + if reads_implicit_props_theme(style) { + return Err(unmapped_theme_error("styled", style)); + } + let name = evaluations.fresh_name(); + let mut skeleton = object.clone_in_with_semantic_ids(builder.allocator()); + let mut leaves = leaves::Leaves { + builder, + params, + name: &name, + values: oxc_allocator::Vec::new_in(builder), + }; + leaves.object(&mut skeleton).map_err(|error| match error { + PropsRulesError::Rules => invalid(), + PropsRulesError::Theme(error) => error, + })?; + let parameter_reads = + values::analysis(builder, params, Expression::new_null_literal(SPAN, builder)); + if reads_unmapped_theme(¶meter_reads) { + return Err(unmapped_theme_error("styled", style)); + } + if let Some(error) = self_reference::error(style) { + return Err(error); + } + *returned = Expression::new_array_expression(SPAN, leaves.values, builder); + let value = Expression::new_call_expression( + SPAN, + function, + None, + oxc_allocator::Vec::from_array_in([identifier(builder, "rest").into()], builder), + false, + builder, + ); + *style = Expression::ObjectExpression(skeleton); + Ok(Some(Invocation { name, value })) +} + +/// Ordinary property callbacks keep their existing invocation and number handling. +fn call_with_props<'a>( + builder: &AstBuilder<'a>, + object: &mut ObjectExpression<'a>, +) -> Result<(), (u32, String)> { + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + continue; + }; + match unwrap_syntax_only_mut(&mut property.value) { + Expression::ObjectExpression(inner) => call_with_props(builder, inner)?, + function @ (Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_)) + if theme_var_reference(function).is_none() => + { + if reads_unmapped_theme(function) { + return Err(unmapped_theme_error("styled", function)); + } + *function = wrap_direct_call(builder, function, &[identifier(builder, "rest")]); + } + _ => {} + } + } + Ok(()) +} diff --git a/libs/extractor/src/extractor/styled_props_leaves.rs b/libs/extractor/src/extractor/styled_props_leaves.rs new file mode 100644 index 000000000..292b3e8a5 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_leaves.rs @@ -0,0 +1,118 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator, TakeIn}; +use oxc_ast::{ + ast::{ + ArrayExpressionElement, ComputedMemberExpression, Expression, FormalParameters, + ObjectExpression, ObjectPropertyKind, PropertyKind, Str, + }, + builder::AstBuilder, +}; +use oxc_span::SPAN; + +use super::{super::identifier, PropsRulesError, values}; +use crate::{ + css_utils::{ + reads_unmapped_theme, theme_leaf_reference, theme_var_reference, unmapped_theme_error, + }, + utils::{ + get_str_by_property_key, unwrap_syntax_only, unwrap_syntax_only_mut, wrap_direct_call, + }, +}; + +pub(super) struct Leaves<'b, 'p, 'a> { + pub builder: &'b AstBuilder<'a>, + pub params: &'p FormalParameters<'a>, + pub name: &'p str, + pub values: oxc_allocator::Vec<'a, ArrayExpressionElement<'a>>, +} + +impl<'a> Leaves<'_, '_, 'a> { + pub fn object(&mut self, object: &mut ObjectExpression<'a>) -> Result<(), PropsRulesError> { + for property in &mut object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return Err(PropsRulesError::Rules); + }; + if property.computed || property.method || property.kind != PropertyKind::Init { + return Err(PropsRulesError::Rules); + } + let key = get_str_by_property_key(&property.key).ok_or(PropsRulesError::Rules)?; + if let Expression::ObjectExpression(inner) = unwrap_syntax_only_mut(&mut property.value) + { + self.object(inner)?; + } else { + self.leaf(&mut property.value, &key)?; + } + property.shorthand = false; + } + Ok(()) + } + + fn leaf(&mut self, value: &mut Expression<'a>, key: &str) -> Result<(), PropsRulesError> { + let builder = self.builder; + let function = matches!( + unwrap_syntax_only(value), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ); + if values::is_written_out(value) { + return Ok(()); + } + let reference = if function { + theme_var_reference(unwrap_syntax_only(value)) + } else { + theme_leaf_reference(self.params, unwrap_syntax_only(value)) + }; + if let Some(reference) = reference { + *value = Expression::new_string_literal( + SPAN, + Str::from_in(reference.as_str(), builder.allocator()), + None, + builder, + ); + return Ok(()); + } + let plain = values::analysis( + builder, + self.params, + value.clone_in_with_semantic_ids(builder.allocator()), + ); + let unmapped = if reads_unmapped_theme(&plain) { + Some(&plain) + } else if function && reads_unmapped_theme(unwrap_syntax_only(value)) { + Some(&*value) + } else { + None + }; + if let Some(unmapped) = unmapped { + return Err(PropsRulesError::Theme(unmapped_theme_error( + "styled", unmapped, + ))); + } + let index = self.values.len().to_string(); + let slot = Expression::ComputedMemberExpression(ComputedMemberExpression::boxed( + SPAN, + Expression::new_identifier(SPAN, Str::from_in(self.name, builder.allocator()), builder), + Expression::new_string_literal( + SPAN, + Str::from_in(index.as_str(), builder.allocator()), + None, + builder, + ), + false, + builder, + )); + self.values.push(value.take_in(builder).into()); + *value = if function { + // A member call would incorrectly give an ordinary function the vector as `this`. + let direct = Expression::new_sequence_expression( + SPAN, + oxc_allocator::Vec::from_array_in([values::zero(builder), slot], builder), + builder, + ); + wrap_direct_call(builder, &direct, &[identifier(builder, "rest")]) + } else if crate::utils::keeps_bare_number(key) { + slot + } else { + values::length(builder, slot) + }; + Ok(()) + } +} diff --git a/libs/extractor/src/extractor/styled_props_self_reference.rs b/libs/extractor/src/extractor/styled_props_self_reference.rs new file mode 100644 index 000000000..2a3966679 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_self_reference.rs @@ -0,0 +1,66 @@ +//! Resolve a named whole-rule callback's own binding before changing its return value. + +use oxc_allocator::{Allocator, CloneIn}; +use oxc_ast::{ + ast::{Expression, ExpressionStatement, Program, Statement}, + builder::AstBuilder, +}; +use oxc_semantic::SemanticBuilder; +use oxc_span::{GetSpan, SPAN, SourceType}; + +use crate::utils::{build_time_error, readable_code, unwrap_syntax_only}; + +pub(super) fn error(style: &Expression<'_>) -> Option<(u32, String)> { + let Expression::FunctionExpression(function) = unwrap_syntax_only(style) else { + return None; + }; + let binding = function.id.as_ref()?; + let name = binding.name.as_str(); + // A fresh clone keeps semantic analysis from replacing the enclosing module's IDs. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let mut value = function.clone_in(&allocator); + // Oxc binds a same-named type parameter over the function's value binding. + // Erase only this type-only list in the analysis clone, never the emitted callback. + value.type_parameters = None; + let body = oxc_allocator::Vec::from_array_in( + [Statement::ExpressionStatement(ExpressionStatement::boxed( + SPAN, + Expression::FunctionExpression(value), + &builder, + ))], + &builder, + ); + let program = Program::new( + SPAN, + SourceType::tsx(), + "", + oxc_allocator::Vec::new_in(&builder), + None, + oxc_allocator::Vec::new_in(&builder), + body, + &builder, + ); + let semantic = SemanticBuilder::new().build(&program).semantic; + let scoping = semantic.scoping(); + // This isolated function binds its own name before any parameters or body bindings. + let symbol = scoping.symbol_ids().next()?; + scoping + .get_resolved_reference_ids(symbol) + .iter() + .any(|reference| scoping.get_reference(*reference).is_value()) + .then(|| { + ( + style.span().start, + build_time_error( + "styled", + &readable_code(style), + &format!("callback `{name}` refers to itself; recursion is not compiled"), + ), + ) + }) +} + +#[cfg(test)] +#[path = "styled_props_self_reference_tests.rs"] +mod tests; diff --git a/libs/extractor/src/extractor/styled_props_self_reference_tests.rs b/libs/extractor/src/extractor/styled_props_self_reference_tests.rs new file mode 100644 index 000000000..76c9818ce --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_self_reference_tests.rs @@ -0,0 +1,30 @@ +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case::parentheses("(CALLBACK)")] +#[case::as_type("(CALLBACK) as Function")] +#[case::satisfies("(CALLBACK) satisfies Function")] +#[case::non_null("(CALLBACK)!")] +#[case::instantiation("(CALLBACK)")] +#[case::combined("(((CALLBACK)) as Function)!")] +fn self_reference_identity_survives_syntax_wrappers( + #[case] wrapper: &str, + #[values(false, true)] shadowed: bool, +) { + // Given: wrappers surround a generic callback with either self or parameter value reads. + let parameter = if shadowed { "rules" } else { "p" }; + let callback = + format!("function rules({parameter}:rules){{return {{color:rules.name}};}}"); + let source = wrapper.replace("CALLBACK", &callback); + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, &source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("invalid wrapper fixture: {error:?}")); + // When: the same guard analyzes the isolated expression beneath every supported wrapper. + let error = super::error(&parsed); + // Then: only references to the function value are refused. + assert_eq!(error.is_some(), !shadowed, "{source}: {error:?}"); +} diff --git a/libs/extractor/src/extractor/styled_props_values.rs b/libs/extractor/src/extractor/styled_props_values.rs new file mode 100644 index 000000000..1e9e52660 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_values.rs @@ -0,0 +1,88 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ + ast::{Expression, FormalParameters}, + builder::AstBuilder, +}; +use oxc_span::{GetSpan, SPAN}; +use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; + +use super::super::{identifier, named_arrow}; + +/// Only theme analysis uses a synthetic per-leaf arrow; emitted execution never does. +pub(super) fn analysis<'a>( + builder: &AstBuilder<'a>, + params: &FormalParameters<'a>, + leaf: Expression<'a>, +) -> Expression<'a> { + Expression::new_arrow_function_expression( + leaf.span(), + false, + None, + oxc_allocator::Box::new_in( + params.clone_in_with_semantic_ids(builder.allocator()), + builder, + ), + None, + leaf.into(), + builder, + ) +} + +pub(super) fn is_written_out(value: &Expression<'_>) -> bool { + let value = crate::utils::unwrap_syntax_only(value); + crate::utils::get_string_by_literal_expression(value).is_some() + || matches!(value, Expression::NullLiteral(_)) +} + +pub(super) fn zero<'a>(builder: &AstBuilder<'a>) -> Expression<'a> { + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + builder, + ) +} + +/// Existing whole-rule conversion: nonzero numbers become px; zero/strings pass through. +pub(super) fn length<'a>(builder: &AstBuilder<'a>, leaf: Expression<'a>) -> Expression<'a> { + let value = || identifier(builder, "__devupValue"); + let is_number = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, value(), builder), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, builder), + builder, + ); + let length = Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, "px", None, builder), + builder, + ); + let length = Expression::new_conditional_expression( + SPAN, + Expression::new_logical_expression( + SPAN, + is_number, + LogicalOperator::And, + Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::StrictInequality, + zero(builder), + builder, + ), + builder, + ), + length, + value(), + builder, + ); + crate::utils::wrap_direct_call( + builder, + &named_arrow(builder, "__devupValue", length), + &[leaf], + ) +} diff --git a/libs/extractor/src/extractor/styled_record_evaluation.rs b/libs/extractor/src/extractor/styled_record_evaluation.rs index c11af549a..72fa73d1a 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation.rs @@ -1,11 +1,14 @@ use oxc_allocator::{CloneIn, FromIn, GetAllocator}; -use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_ast::{ + ast::{ArrowFunctionBody, Expression}, + builder::AstBuilder, +}; use oxc_span::{GetSpan, SPAN}; use crate::utils::is_same_expression; /// Ordered reads shared by the class and variable expressions of one render. -pub(super) struct Evaluations<'b, 'a> { +pub(crate) struct Evaluations<'b, 'a> { pub builder: &'b AstBuilder<'a>, source: String, pub bindings: Vec<(String, Expression<'a>)>, @@ -150,18 +153,18 @@ pub(super) fn visit_style_expressions<'a>( } } -pub(super) fn bind_render<'a>( +/// Ordered captures owned directly by a compiler-generated render arrow. +pub(crate) fn bind_render_body<'a>( builder: &AstBuilder<'a>, bindings: Vec<(String, Expression<'a>)>, body: Expression<'a>, -) -> Expression<'a> { +) -> ArrowFunctionBody<'a> { use oxc_allocator::Vec as ArenaVec; use oxc_ast::ast::{ - ArrowFunctionBody, BindingPattern, FormalParameterKind, FormalParameters, Statement, - VariableDeclaration, VariableDeclarationKind, VariableDeclarator, + BindingPattern, Statement, VariableDeclaration, VariableDeclarationKind, VariableDeclarator, }; if bindings.is_empty() { - return body; + return body.into(); } // Sibling declarations preserve dependent read order without one scope per capture. let mut statements = ArenaVec::with_capacity_in(bindings.len() + 1, builder); @@ -202,6 +205,19 @@ pub(super) fn bind_render<'a>( ))); } statements.push(Statement::new_return_statement(SPAN, Some(body), builder)); + ArrowFunctionBody::new_function_body(SPAN, ArenaVec::new_in(builder), statements, builder) +} + +pub(crate) fn bind_render<'a>( + builder: &AstBuilder<'a>, + bindings: Vec<(String, Expression<'a>)>, + body: Expression<'a>, +) -> Expression<'a> { + use oxc_allocator::Vec as ArenaVec; + use oxc_ast::ast::{FormalParameterKind, FormalParameters}; + if bindings.is_empty() { + return body; + } let arrow = Expression::new_arrow_function_expression( SPAN, false, @@ -214,7 +230,7 @@ pub(super) fn bind_render<'a>( builder, ), None, - ArrowFunctionBody::new_function_body(SPAN, ArenaVec::new_in(builder), statements, builder), + bind_render_body(builder, bindings, body), builder, ); Expression::new_call_expression( diff --git a/libs/extractor/src/extractor/styled_record_evaluation_tests.rs b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs index f6c326ec6..a1069c78b 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation_tests.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation_tests.rs @@ -8,6 +8,29 @@ use rstest::rstest; use super::*; use crate::utils::readable_code; +#[test] +fn repair_745_empty_captures_keep_the_callers_arguments() { + // Given: an expression whose arguments belong to its enclosing function. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let body = Parser::new(&allocator, "arguments[0]", SourceType::mjs()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + + // When: bind no captures, then execute the emitted expression in its authored scope. + let render = bind_render(&builder, Vec::new(), body); + let script = format!("(function(){{return {};}})(7)", readable_code(&render)); + let result = Context::default() + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}")); + + // Then: no zero-argument wrapper steals the caller's arguments object. + assert_eq!( + result.as_number().map(f64::to_bits), + Some(7.0_f64.to_bits()) + ); +} + #[rstest] #[case::equal_true(true, true)] #[case::equal_false(false, false)] diff --git a/libs/extractor/src/extractor/styled_render_body_tests.rs b/libs/extractor/src/extractor/styled_render_body_tests.rs new file mode 100644 index 000000000..f27de23a6 --- /dev/null +++ b/libs/extractor/src/extractor/styled_render_body_tests.rs @@ -0,0 +1,237 @@ +use boa_engine::{Context, Source}; +use oxc_allocator::Allocator; +use oxc_ast::ast::{ArrowFunctionBody, Statement, VariableDeclarationKind}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +use super::*; + +fn expression<'a>(builder: &AstBuilder<'a>, source: &str) -> Expression<'a> { + let expression = Parser::new( + builder.allocator(), + source.trim_end_matches(';'), + SourceType::mjs(), + ) + .parse_expression() + .unwrap_or_else(|error| panic!("invalid render fixture: {error:?}")); + unwrap_syntax_only(&expression).clone_in(builder.allocator()) +} + +fn component<'a>( + builder: &AstBuilder<'a>, + render: StyledRender<'_, 'a>, + jsx: bool, +) -> Expression<'a> { + let source_type = if jsx { + SourceType::tsx() + } else { + SourceType::mjs() + }; + let parsed = Parser::new(builder.allocator(), "", source_type).parse(); + let mut helper = StyledJsx::reserve(&parsed.program); + create_styled_component( + builder, + &Base::named("div".to_string()), + render, + &mut helper, + ) +} + +#[rstest] +fn direct_body_when_generated_component_has_captures(#[values(false, true)] jsx: bool) { + // Given: ordered captures with a dependency on the preceding binding. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let bindings = vec![ + ("first".to_string(), expression(&builder, "readFirst();")), + ( + "second".to_string(), + expression(&builder, "readSecond(first);"), + ), + ]; + + // When: build the actual props-taking component arrow in either output mode. + let render = component( + &builder, + StyledRender { + class_name: &None, + style_vars: &None, + bindings, + withheld: &[], + capability: None, + execution: &Execution::Replay, + markers: None, + }, + jsx, + ); + + // Then: the arrow directly owns sibling const declarations and the element return. + let Expression::ArrowFunctionExpression(arrow) = render else { + panic!("generated component must be an arrow"); + }; + assert_eq!(arrow.params.items.len(), 1); + let ArrowFunctionBody::FunctionBody(body) = &arrow.body else { + panic!("captured component must directly own a block body"); + }; + let [ + Statement::VariableDeclaration(first), + Statement::VariableDeclaration(second), + Statement::ReturnStatement(returned), + ] = body.statements.as_slice() + else { + panic!("captures must be sibling declarations followed by the render return"); + }; + for (declaration, name, initializer) in [ + (first, "first", "readFirst()"), + (second, "second", "readSecond(first)"), + ] { + assert_eq!(declaration.kind, VariableDeclarationKind::Const); + let [binding] = declaration.declarations.as_slice() else { + panic!("each capture must have its own declaration"); + }; + let identifier = binding + .id + .get_binding_identifier() + .unwrap_or_else(|| panic!("capture needs an identifier")); + assert_eq!(identifier.name, name); + let Some(Expression::SequenceExpression(sequence)) = &binding.init else { + panic!("capture must retain its sequence name barrier"); + }; + let [Expression::NumericLiteral(zero), value] = sequence.expressions.as_slice() else { + panic!("capture name barrier must be exactly (0, initializer)"); + }; + assert_eq!(zero.value.to_bits(), 0.0_f64.to_bits()); + assert_eq!(readable_code(value), initializer); + } + match returned + .argument + .as_ref() + .unwrap_or_else(|| panic!("render needs a return value")) + { + Expression::JSXElement(_) => assert!(jsx), + Expression::CallExpression(call) => { + assert!(!jsx); + assert!(matches!(&call.callee, Expression::Identifier(name) + if name.name == "__devupJsx0")); + } + _ => panic!("component must return its element, not a capture IIFE"), + } +} + +#[rstest] +fn concise_body_when_generated_component_has_no_captures(#[values(false, true)] jsx: bool) { + // Given: no per-render captures. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + + // When: build the generated component without bindings. + let render = component( + &builder, + StyledRender { + class_name: &None, + style_vars: &None, + bindings: Vec::new(), + withheld: &[], + capability: None, + execution: &Execution::Replay, + markers: None, + }, + jsx, + ); + + // Then: its element remains a concise expression body. + let Expression::ArrowFunctionExpression(arrow) = render else { + panic!("generated component must be an arrow"); + }; + assert_eq!( + matches!( + arrow + .body + .as_expression() + .unwrap_or_else(|| panic!("empty captures must stay concise")), + Expression::JSXElement(_) + ), + jsx + ); +} + +#[rstest] +#[case::class("class { static { trace.push('class:' + this.name); } }", true)] +#[case::function("function() {}", false)] +fn names_and_evaluation_when_captures_move_into_generated_render( + #[case] initializer: &str, + #[case] class_observer: bool, +) { + // Given: an anonymous definition and a dependent read of destructured rest props. + let allocator = Allocator::default(); + let builder = AstBuilder::new(&allocator); + let class_name = Some(expression(&builder, "__devupRecord1;")); + let bindings = vec![ + ( + "__devupRecord0".to_string(), + expression(&builder, &format!("({initializer});")), + ), + ( + "__devupRecord1".to_string(), + expression( + &builder, + "(trace.push('capture:' + __devupRecord0.name + ':' + rest.value), rest.value);", + ), + ), + ]; + let render = component( + &builder, + StyledRender { + class_name: &class_name, + style_vars: &None, + bindings, + withheld: &[], + capability: None, + execution: &Execution::Replay, + markers: None, + }, + false, + ); + let script = format!( + r" + const trace = []; + const __devupJsx0 = (tag, props) => props.className; + let current = 'one'; + const props = {{ + get style() {{ trace.push('style'); return {{}}; }}, + get className() {{ trace.push('className'); }}, + get as() {{ trace.push('as'); }}, + get forwardedAs() {{ trace.push('forwardedAs'); }}, + get value() {{ trace.push('rest'); return current; }} + }}; + const render = {}; + trace.push('defined'); + JSON.stringify([render(props), (current = 'two', render(props)), trace]); + ", + readable_code(&render) + ); + + // When: execute the emitted component through two renders. + let mut context = Context::default(); + let result = context + .eval(Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("generated render evaluation failed: {error}")); + let result = result + .as_string() + .unwrap_or_else(|| panic!("observation must be JSON text")) + .to_std_string_escaped(); + + // Then: destructuring precedes ordered captures each time, with no inferred name. + let mut trace = vec!["defined".to_string()]; + for value in ["one", "two"] { + trace.extend(["style", "className", "as", "forwardedAs", "rest"].map(str::to_string)); + if class_observer { + trace.push("class:".to_string()); + } + trace.push(format!("capture::{value}")); + } + let expected = serde_json::to_string(&("one", "two", trace)) + .unwrap_or_else(|error| panic!("expected observation must serialize: {error}")); + assert_eq!(result, expected); +} diff --git a/libs/extractor/src/extractor/styled_rule_arrays.rs b/libs/extractor/src/extractor/styled_rule_arrays.rs new file mode 100644 index 000000000..e87d5bbef --- /dev/null +++ b/libs/extractor/src/extractor/styled_rule_arrays.rs @@ -0,0 +1,124 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::{ast::Expression, builder::AstBuilder}; +use oxc_span::{GetSpan, SPAN}; +use oxc_syntax::operator::LogicalOperator; + +use crate::utils::{is_pure, unwrap_syntax_only, unwrap_syntax_only_mut}; + +/// Distribute only the controllers the existing pure-only policy permits. +/// Authored evaluations, when prepared, are already shared render bindings. +pub(super) fn distribute_conditions<'a>( + builder: &AstBuilder<'a>, + part: &mut Expression<'a>, + refused: &[u32], +) { + let clone = |expression: &Expression<'a>| expression.clone_in(builder.allocator()); + let parts_of = |expression: &Expression<'a>| match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => array + .elements + .iter() + .map(|element| element.as_expression().map(clone)) + .collect::>>(), + _ => None, + }; + let is_empty = |expression: &Expression<'a>| { + matches!( + unwrap_syntax_only(expression), + Expression::NullLiteral(_) | Expression::BooleanLiteral(_) + ) + }; + let null = || Expression::new_null_literal(SPAN, builder); + let elements: Vec> = match unwrap_syntax_only_mut(part) { + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(element) = element.as_expression_mut() { + distribute_conditions(builder, element, refused); + } + } + return; + } + Expression::LogicalExpression(logical) + if logical.operator == LogicalOperator::And + && is_pure(&logical.left) + && !refused.contains(&logical.span.start) + && let Some(parts) = parts_of(&logical.right) => + { + parts + .into_iter() + .map(|part| { + Expression::new_logical_expression( + SPAN, + clone(&logical.left), + LogicalOperator::And, + part, + builder, + ) + }) + .collect() + } + Expression::ConditionalExpression(conditional) + if is_pure(&conditional.test) + && !refused.contains(&conditional.span.start) + && let Some(chosen) = parts_of(&conditional.consequent) + .or_else(|| is_empty(&conditional.consequent).then(Vec::new)) + && let Some(otherwise) = parts_of(&conditional.alternate) + .or_else(|| is_empty(&conditional.alternate).then(Vec::new)) => + { + let choose = |part, chosen: bool| { + let (consequent, alternate) = if chosen { + (part, null()) + } else { + (null(), part) + }; + Expression::new_conditional_expression( + SPAN, + clone(&conditional.test), + consequent, + alternate, + builder, + ) + }; + chosen + .into_iter() + .map(|part| choose(part, true)) + .chain(otherwise.into_iter().map(|part| choose(part, false))) + .collect() + } + _ => return, + }; + *part = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(elements.into_iter().map(Into::into), builder), + builder, + ); + distribute_conditions(builder, part, refused); +} + +pub(crate) fn refused_controllers(expression: &Expression<'_>, refused: &mut Vec) { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + refused_controllers(element, refused); + } + } + } + Expression::ConditionalExpression(choice) => { + if !is_pure(&choice.test) { + refused.push(choice.span.start); + } + refused_controllers(&choice.consequent, refused); + refused_controllers(&choice.alternate, refused); + } + Expression::LogicalExpression(logical) => { + if !is_pure(&logical.left) { + refused.push(logical.span().start); + } + if logical.operator != LogicalOperator::And { + refused_controllers(&logical.left, refused); + } + refused_controllers(&logical.right, refused); + } + _ => {} + } +} diff --git a/libs/extractor/src/implicit_props_theme.rs b/libs/extractor/src/implicit_props_theme.rs new file mode 100644 index 000000000..567278ec6 --- /dev/null +++ b/libs/extractor/src/implicit_props_theme.rs @@ -0,0 +1,234 @@ +//! Error detection for alternate access to a callback's actual props argument. + +use oxc_ast::ast::{ + ArrowFunctionBody, ArrowFunctionExpression, BindingPattern, BlockStatement, CatchClause, Class, + ComputedMemberExpression, Expression, ForInStatement, ForOfStatement, ForStatement, + ForStatementInit, ForStatementLeft, Function, FunctionBody, StaticBlock, + StaticMemberExpression, SwitchStatement, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_syntax::scope::ScopeFlags; + +use crate::utils::{get_string_by_literal_expression, unwrap_syntax_only}; + +#[path = "implicit_props_theme_scope.rs"] +mod scope; +use super::theme_callback::{Body, Callback}; +use scope::Bindings; + +#[cfg(test)] +#[path = "implicit_props_theme_tests.rs"] +mod tests; + +/// These roots deliberately do not participate in static theme-path mapping. +pub(crate) fn reads(expr: &Expression<'_>) -> bool { + let Some(Callback { params, body }) = Callback::read(unwrap_syntax_only(expr)) else { + return false; + }; + let arguments = matches!(body, Body::Function(_)); + let rest = if params.items.is_empty() { + params + .rest + .as_ref() + .and_then(|rest| match &rest.rest.argument { + BindingPattern::BindingIdentifier(identifier) => Some(identifier.name.as_str()), + _ => None, + }) + } else { + None + }; + let mut reads = ThemeReads { + roots: Roots { arguments, rest }, + found: false, + }; + // The callback itself establishes roots; nested functions only inherit rest. + let mut bindings = Bindings::new(reads.roots); + bindings.visit_formal_parameters(params); + reads.roots.arguments &= !bindings.arguments; + reads.visit_formal_parameters(params); + match body { + Body::Function(body) => { + if let Some(body) = body { + reads.visit_root_body(body); + } + } + Body::Arrow(body) => match body { + ArrowFunctionBody::FunctionBody(body) => reads.visit_root_body(body), + body => reads.visit_arrow_function_body(body), + }, + } + reads.found +} + +#[derive(Clone, Copy)] +struct Roots<'n> { + arguments: bool, + rest: Option<&'n str>, +} + +impl Roots<'_> { + fn is_props(self, expr: &Expression<'_>) -> bool { + let Expression::ComputedMemberExpression(member) = unwrap_syntax_only(expr) else { + return false; + }; + if get_string_by_literal_expression(&member.expression).is_none_or(|key| key != "0") { + return false; + } + matches!(unwrap_syntax_only(&member.object), Expression::Identifier(identifier) + if (self.arguments && identifier.name == "arguments") + || self.rest.is_some_and(|name| identifier.name == name)) + } + + fn shadowed(self, bindings: &Bindings<'_>) -> Self { + Self { + arguments: self.arguments && !bindings.arguments, + rest: self.rest.filter(|_| !bindings.rest), + } + } +} + +struct ThemeReads<'n> { + roots: Roots<'n>, + found: bool, +} + +impl ThemeReads<'_> { + fn visit_root_body(&mut self, body: &FunctionBody<'_>) { + let saved = self.roots; + // Root `var` redeclarations retain the rest parameter / own arguments root. + // With non-simple parameters the body's var binding copies that value. + // Function declarations can replace that value, so still mask them here. + self.roots = saved.shadowed(&Bindings::lexical(saved, &body.statements)); + walk::walk_function_body(self, body); + self.roots = saved; + } +} + +impl<'a> Visit<'a> for ThemeReads<'_> { + fn visit_static_member_expression(&mut self, it: &StaticMemberExpression<'a>) { + self.found |= it.property.name == "theme" && self.roots.is_props(&it.object); + walk::walk_static_member_expression(self, it); + } + + fn visit_computed_member_expression(&mut self, it: &ComputedMemberExpression<'a>) { + self.found |= get_string_by_literal_expression(&it.expression) + .is_some_and(|key| key == "theme") + && self.roots.is_props(&it.object); + walk::walk_computed_member_expression(self, it); + } + + fn visit_function(&mut self, it: &Function<'a>, flags: ScopeFlags) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + bindings.visit_formal_parameters(&it.params); + if let Some(id) = &it.id { + bindings.visit_binding_identifier(id); + } + self.roots = saved.shadowed(&bindings); + self.roots.arguments = false; + walk::walk_function(self, it, flags); + self.roots = saved; + } + + fn visit_arrow_function_expression(&mut self, it: &ArrowFunctionExpression<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + bindings.visit_formal_parameters(&it.params); + self.roots = saved.shadowed(&bindings); + walk::walk_arrow_function_expression(self, it); + self.roots = saved; + } + + fn visit_function_body(&mut self, it: &FunctionBody<'a>) { + let saved = self.roots; + self.roots = saved + .shadowed(&Bindings::function_vars(saved, &it.statements)) + .shadowed(&Bindings::lexical(saved, &it.statements)); + walk::walk_function_body(self, it); + self.roots = saved; + } + + fn visit_block_statement(&mut self, it: &BlockStatement<'a>) { + let saved = self.roots; + self.roots = saved.shadowed(&Bindings::lexical(saved, &it.body)); + walk::walk_block_statement(self, it); + self.roots = saved; + } + fn visit_catch_clause(&mut self, it: &CatchClause<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let Some(param) = &it.param { + bindings.visit_binding_pattern(¶m.pattern); + } + self.roots = saved.shadowed(&bindings); + walk::walk_catch_clause(self, it); + self.roots = saved; + } + + fn visit_for_statement(&mut self, it: &ForStatement<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let Some(ForStatementInit::VariableDeclaration(declaration)) = &it.init { + bindings.lexical_declaration(declaration); + } + self.roots = saved.shadowed(&bindings); + walk::walk_for_statement(self, it); + self.roots = saved; + } + + fn visit_for_in_statement(&mut self, it: &ForInStatement<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let ForStatementLeft::VariableDeclaration(declaration) = &it.left { + bindings.lexical_declaration(declaration); + } + self.roots = saved.shadowed(&bindings); + walk::walk_for_in_statement(self, it); + self.roots = saved; + } + + fn visit_for_of_statement(&mut self, it: &ForOfStatement<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let ForStatementLeft::VariableDeclaration(declaration) = &it.left { + bindings.lexical_declaration(declaration); + } + self.roots = saved.shadowed(&bindings); + walk::walk_for_of_statement(self, it); + self.roots = saved; + } + + fn visit_switch_statement(&mut self, it: &SwitchStatement<'a>) { + self.visit_expression(&it.discriminant); + let saved = self.roots; + for case in &it.cases { + self.roots = self + .roots + .shadowed(&Bindings::lexical(saved, &case.consequent)); + } + for case in &it.cases { + self.visit_switch_case(case); + } + self.roots = saved; + } + + fn visit_class(&mut self, it: &Class<'a>) { + let saved = self.roots; + let mut bindings = Bindings::new(saved); + if let Some(id) = &it.id { + bindings.visit_binding_identifier(id); + } + self.roots = saved.shadowed(&bindings); + walk::walk_class(self, it); + self.roots = saved; + } + + fn visit_static_block(&mut self, it: &StaticBlock<'a>) { + let saved = self.roots; + self.roots = saved + .shadowed(&Bindings::function_vars(saved, &it.body)) + .shadowed(&Bindings::lexical(saved, &it.body)); + walk::walk_static_block(self, it); + self.roots = saved; + } +} diff --git a/libs/extractor/src/implicit_props_theme_scope.rs b/libs/extractor/src/implicit_props_theme_scope.rs new file mode 100644 index 000000000..92d927c9b --- /dev/null +++ b/libs/extractor/src/implicit_props_theme_scope.rs @@ -0,0 +1,93 @@ +//! Binding ownership for the implicit callback-props detector. + +use oxc_ast::ast::{ + BindingIdentifier, Class, Expression, Function, Statement, VariableDeclaration, + VariableDeclarationKind, +}; +use oxc_ast_visit::Visit; +use oxc_syntax::scope::ScopeFlags; + +use super::Roots; + +/// Only binding names are collected; pattern defaults never introduce bindings here. +pub(super) struct Bindings<'n> { + roots: Roots<'n>, + pub(super) arguments: bool, + pub(super) rest: bool, +} + +impl<'n> Bindings<'n> { + pub(super) const fn new(roots: Roots<'n>) -> Self { + Self { + roots, + arguments: false, + rest: false, + } + } + + /// Direct declarations belong to this block, not to its catch/loop/switch children. + pub(super) fn lexical<'a>(roots: Roots<'n>, statements: &[Statement<'a>]) -> Self { + let mut bindings = Self::new(roots); + for statement in statements { + match statement { + Statement::VariableDeclaration(declaration) => { + bindings.lexical_declaration(declaration); + } + Statement::FunctionDeclaration(function) => { + if let Some(id) = &function.id { + bindings.visit_binding_identifier(id); + } + } + Statement::ClassDeclaration(class) => { + if let Some(id) = &class.id { + bindings.visit_binding_identifier(id); + } + } + _ => {} + } + } + bindings + } + + pub(super) fn lexical_declaration(&mut self, declaration: &VariableDeclaration<'_>) { + if declaration.kind != VariableDeclarationKind::Var { + for declarator in &declaration.declarations { + self.visit_binding_pattern(&declarator.id); + } + } + } + + /// Var declarations cross statement scopes, but never function or class boundaries. + pub(super) fn function_vars<'a>(roots: Roots<'n>, statements: &[Statement<'a>]) -> Self { + let mut vars = FunctionVars(Self::new(roots)); + for statement in statements { + vars.visit_statement(statement); + } + vars.0 + } +} + +impl<'a> Visit<'a> for Bindings<'_> { + fn visit_binding_identifier(&mut self, it: &BindingIdentifier<'a>) { + self.arguments |= it.name == "arguments"; + self.rest |= self.roots.rest.is_some_and(|name| it.name == name); + } + + fn visit_expression(&mut self, _: &Expression<'a>) {} +} + +struct FunctionVars<'n>(Bindings<'n>); + +impl<'a> Visit<'a> for FunctionVars<'_> { + fn visit_variable_declaration(&mut self, it: &VariableDeclaration<'a>) { + if it.kind == VariableDeclarationKind::Var { + for declarator in &it.declarations { + self.0.visit_binding_pattern(&declarator.id); + } + } + } + + fn visit_expression(&mut self, _: &Expression<'a>) {} + fn visit_function(&mut self, _: &Function<'a>, _: ScopeFlags) {} + fn visit_class(&mut self, _: &Class<'a>) {} +} diff --git a/libs/extractor/src/implicit_props_theme_tests.rs b/libs/extractor/src/implicit_props_theme_tests.rs new file mode 100644 index 000000000..f8e299141 --- /dev/null +++ b/libs/extractor/src/implicit_props_theme_tests.rs @@ -0,0 +1,151 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case::catch_outside("(...all)=>{try{}catch(all){}return all[0].theme;}", true)] +#[case::rest_array("(...[props])=>props.theme", false)] +#[case::rest_object("(...{first})=>first.theme", false)] +#[case::other_argument("function(){return arguments[1].theme;}", false)] +#[case::dynamic_argument("(...all)=>all[index].theme", false)] +#[case::catch_inside("(...all)=>{try{}catch(all){return all[0].theme;}}", false)] +#[case::catch_pattern("(...all)=>{try{}catch({all}){return all[0].theme;}}", false)] +#[case::catch_default_outer("(...all)=>{try{}catch({x=all[0].theme}){}}", true)] +#[case::catch_body_lexical("(...all)=>{try{}catch({x=all[0].theme}){let all;}}", true)] +#[case::arguments_catch_outside( + "function(){try{}catch(arguments){}return arguments[0].theme;}", + true +)] +#[case::arguments_catch_inside( + "function(){try{}catch(arguments){return arguments[0].theme;}}", + false +)] +#[case::for_outside("(...all)=>{for(let all=0;false;){}return all[0].theme;}", true)] +#[case::for_inside("(...all)=>{for(let all=[];all[0].theme;all[0].theme){}}", false)] +#[case::for_initializer("(...all)=>{for(let all=all[0].theme;false;){}}", false)] +#[case::for_body_lexical("(...all)=>{for(;all[0].theme;){let all;}}", true)] +#[case::for_in_outside("(...all)=>{for(const all in {}){}return all[0].theme;}", true)] +#[case::for_in_inside("(...all)=>{for(const all in {}){all[0].theme;}}", false)] +#[case::for_in_rhs("(...all)=>{for(const all in all[0].theme){}}", false)] +#[case::for_of_outside("(...all)=>{for(const all of []){}return all[0].theme;}", true)] +#[case::for_of_inside("(...all)=>{for(const all of []){all[0].theme;}}", false)] +#[case::for_of_rhs("(...all)=>{for(const all of all[0].theme){}}", false)] +#[case::arguments_for_outside( + "function(){for(let arguments;false;){}return arguments[0].theme;}", + true +)] +#[case::arguments_for_in_inside("function(){for(let arguments in {}){arguments[0].theme;}}", false)] +#[case::arguments_for_of_outside( + "function(){for(let arguments of []){}return arguments[0].theme;}", + true +)] +#[case::switch_outside("(...all)=>{switch(0){case 0:let all;}return all[0].theme;}", true)] +#[case::switch_discriminant("(...all)=>{switch(all[0].theme){case 0:let all;}}", true)] +#[case::switch_shared_cases( + "(...all)=>{switch(0){case all[0].theme:break;default:let all;}}", + false +)] +#[case::switch_nested_block("(...all)=>{switch(0){case all[0].theme:{let all;}}}", true)] +#[case::arguments_switch_outside( + "function(){switch(0){case 0:let arguments;}return arguments[0].theme;}", + true +)] +#[case::block_lexical_outside("(...all)=>{{let all;}return all[0].theme;}", true)] +#[case::block_lexical_inside("(...all)=>{{all[0].theme;let all;}}", false)] +#[case::block_function_outside("(...all)=>{{function all(){}}return all[0].theme;}", true)] +#[case::block_function_inside("(...all)=>{{function all(){}all[0].theme;}}", false)] +#[case::block_class_inside("(...all)=>{{class all{}all[0].theme;}}", false)] +#[case::root_rest_var("(...all)=>{var all;return all[0].theme;}", true)] +#[case::root_rest_nested_var("(...all)=>{{var all;}return all[0].theme;}", true)] +#[case::root_arguments_var("function(){var arguments;return arguments[0].theme;}", true)] +#[case::root_arguments_nested_var( + "function(){if(false){var arguments;}return arguments[0].theme;}", + true +)] +#[case::root_arguments_default_var("function(p=0){var arguments;return arguments[0].theme;}", true)] +#[case::root_named_arguments("function arguments(){return arguments[0].theme;}", true)] +#[case::root_rest_named_arguments( + "(...arguments)=>{var arguments;return arguments[0].theme;}", + true +)] +#[case::root_arguments_parameter("function(arguments){return arguments[0].theme;}", false)] +#[case::root_arguments_function( + "function(){function arguments(){}return arguments[0].theme;}", + false +)] +#[case::root_rest_function("(...all)=>{function all(){}return all[0].theme;}", false)] +#[case::nested_block_var("(...all)=>(()=>{all[0].theme;{var all;}})()", false)] +#[case::nested_if_var("(...all)=>(()=>{if(false){var all;}return all[0].theme;})()", false)] +#[case::nested_catch_var( + "(...all)=>(()=>{try{}catch(e){var all;}return all[0].theme;})()", + false +)] +#[case::nested_for_var("(...all)=>(()=>{for(var all;false;){}return all[0].theme;})()", false)] +#[case::nested_for_in_var("(...all)=>(()=>{for(var all in {}){}return all[0].theme;})()", false)] +#[case::nested_for_of_var("(...all)=>(()=>{for(var all of []){}return all[0].theme;})()", false)] +#[case::nested_switch_var( + "(...all)=>(()=>{switch(0){case 0:var all;}return all[0].theme;})()", + false +)] +#[case::nested_normal_rest_var( + "(...all)=>function(){if(false){var all;}return all[0].theme;}", + false +)] +#[case::nested_normal_arguments( + "function(){return function(){return arguments[0].theme;};}", + false +)] +#[case::nested_arrow_arguments("function(){return ()=>arguments[0].theme;}", true)] +#[case::nested_arrow_arguments_var( + "function(){return ()=>{if(false){var arguments;}return arguments[0].theme;};}", + false +)] +#[case::nested_arrow_param("(...all)=>(all)=>all[0].theme", false)] +#[case::nested_normal_name("(...all)=>function all(){return all[0].theme;}", false)] +#[case::default_before_body_var("(...all)=>(x=all[0].theme)=>{var all;}", true)] +#[case::default_before_body_let("(...all)=>(x=all[0].theme)=>{let all;}", true)] +#[case::root_default_before_let("function(x=arguments[0].theme){let arguments;}", true)] +#[case::parameter_order("function(p,x=arguments[0].theme){return x;}", true)] +#[case::later_parameter_tdz("(...all)=>(x=all[0].theme,all)=>x", false)] +#[case::destructured_parameter_default("(...all)=>({x=all[0].theme})=>x", true)] +#[case::inner_function_var_boundary( + "(...all)=>(()=>{function local(){var all;}return all[0].theme;})()", + true +)] +#[case::inner_arrow_var_boundary( + "(...all)=>(()=>{const local=()=>{var all;};return all[0].theme;})()", + true +)] +#[case::inner_class_var_boundary( + "(...all)=>(()=>{class Local{static{var all;}}return all[0].theme;})()", + true +)] +#[case::class_expression_name("(...all)=>(class all{static x=all[0].theme;})", false)] +#[case::class_expression_outer("(...all)=>(class Local{static x=all[0].theme;})", true)] +#[case::static_block_var("(...all)=>(class Local{static{all[0].theme;{var all;}}})", false)] +#[case::static_block_restore( + "(...all)=>(class Local{static{var all;}static x=all[0].theme;})", + true +)] +fn implicit_theme_detection_follows_binding_owners(#[case] callback: &str, #[case] expected: bool) { + // Given: a parsed callback with a distinct lexical or function binding owner. + let arena = Allocator::default(); + let source = format!("({callback});"); + let parsed = Parser::new(&arena, &source, SourceType::script()).parse(); + assert_eq!( + parsed.diagnostics.len(), + 0, + "{callback}: {:?}", + parsed.diagnostics + ); + assert!(!parsed.fatal_error, "{callback}"); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expected callback expression: {callback}"); + }; + // When: the same detector used by the located guard resolves implicit props roots. + let observed = super::reads(&statement.expression); + // Then: only the actual callback roots are recognized, without invoking Boa. + assert_eq!(observed, expected, "{callback}"); +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 1810c6520..988066a93 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -136,6 +136,20 @@ impl LibraryNumbers { } Expression::LogicalExpression(logical) => self.pixelify(&logical.right), Expression::ParenthesizedExpression(inner) => self.pixelify(&inner.expression), + Expression::ArrowFunctionExpression(arrow) => { + let rules = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + if let Some(rules) = rules { + self.pixelify(rules); + } + } + Expression::FunctionExpression(function) => { + if let Some(rules) = function.body.as_deref().and_then(returned) { + self.pixelify(rules); + } + } rules => self.marks.push(Self::mark(rules)), } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 89fc2d516..6ad504ebf 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -31,6 +31,9 @@ use crate::extractor::extract_style_from_expression::{ use crate::stylex::StylexFunction; use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; +#[path = "consumed_references.rs"] +mod consumed_references; +use consumed_references::ConsumedReferences; #[cfg(test)] #[path = "imported_constants_px_tests.rs"] mod px_tests; @@ -219,12 +222,88 @@ pub(crate) struct Inlined { pub css_failures: Vec, pub unknown: Unknown, pub changed: Changed, + /// Style objects declared below the top level that styles read, which + /// code elsewhere reads too, so the build cannot tell they are unchanged + pub errors: Vec<(u32, String)>, /// The semantic analysis of the program as parsed, so the visitor reuses /// it: constants inlined later leave the ids of surviving references as /// they are. `None` when no analysis was needed pub scoping: Option>, } +/// A `const` below the top level of the module holding an object or array +/// literal that is all written out, which styles read as they read top-level +/// constants when nothing else reads it +struct LocalConstant { + name: String, + /// Where its binding is written + binding: u32, + value: Constant, +} + +/// The `const` declarations holding object or array literals all written out +#[derive(Default)] +struct LocalConstants { + found: Vec, +} + +/// `expression` as the value it writes out, when every part of it is written +/// out as a literal +fn written_out(expression: &Expression<'_>) -> Option { + match expression { + Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())), + Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), + Expression::NullLiteral(_) => Some(Constant::Null), + Expression::ParenthesizedExpression(inner) => written_out(&inner.expression), + Expression::ArrayExpression(array) => array + .elements + .iter() + .map(|element| written_out(element.as_expression()?)) + .collect::>>() + .map(|values| Constant::Array(Rc::new(values))), + Expression::ObjectExpression(object) => object + .properties + .iter() + .map(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return None; + }; + if property.computed + || property.method + || property.kind != oxc_ast::ast::PropertyKind::Init + { + return None; + } + let key = crate::utils::get_str_by_property_key(&property.key)?; + Some((key.to_string(), written_out(&property.value)?)) + }) + .collect::>>() + .map(|entries| Constant::Record(Rc::new(entries))), + expression => crate::utils::js_number_literal(expression).map(Constant::Number), + } +} + +impl<'a> Visit<'a> for LocalConstants { + fn visit_variable_declaration(&mut self, declaration: &oxc_ast::ast::VariableDeclaration<'a>) { + if declaration.kind == VariableDeclarationKind::Const { + for declarator in &declaration.declarations { + if let oxc_ast::ast::BindingPattern::BindingIdentifier(binding) = &declarator.id + && let Some(init) = &declarator.init + && let Some(value) = written_out(init) + && matches!(value, Constant::Record(_) | Constant::Array(_)) + { + self.found.push(LocalConstant { + name: binding.name.to_string(), + binding: binding.span.start, + value, + }); + } + } + } + walk::walk_variable_declaration(self, declaration); + } +} + /// Bindings styles read that hold an object or array code changes, whole or /// in some member #[derive(Default, Clone)] @@ -484,18 +563,27 @@ fn inline_in<'a>( } } } + style.visit_program(program); let mut read = StyleReads { style: &style, css_props: &css_props, names: FxHashSet::default(), + locals: FxHashSet::default(), depth: 0, class_names: Vec::new(), }; read.visit_program(program); - if read.names.is_empty() { + if read.names.is_empty() && read.locals.is_empty() { return Inlined::default(); } let names = read.names; + let mut locals = LocalConstants::default(); + locals.visit_program(program); + locals.found.retain(|local| { + read.locals + .iter() + .any(|symbol| scoping.symbol_span(*symbol).start == local.binding) + }); let mut modules = Modules { resolver, option, @@ -575,7 +663,7 @@ fn inline_in<'a>( // Constants are only worth reading when a style reads a name that may // hold one let reads_math = names.contains("Math") && !scope.binds("Math"); - if !reads_math && !names.iter().any(|name| scope.binds(name)) { + if !reads_math && locals.found.is_empty() && !names.iter().any(|name| scope.binds(name)) { return Inlined::default(); } for name in &names { @@ -632,8 +720,21 @@ fn inline_in<'a>( reads_math }; inlined.dependencies = modules.exports.into_keys().collect(); + let mut declared = Vec::new(); + for symbol in scoping.symbol_ids() { + if scoping.symbol_scope_id(symbol) != scoping.root_scope_id() + && scoping.symbol_flags(symbol).is_const_variable() + && let Some(local) = locals + .found + .iter() + .find(|local| local.binding == scoping.symbol_span(symbol).start) + { + symbols.insert(symbol, local.value.clone()); + declared.push((symbol, local)); + } + } if !symbols.is_empty() || reads_math { - Inline { + let mut inline = Inline { ast_builder, scoping, symbols: &symbols, @@ -646,9 +747,18 @@ fn inline_in<'a>( px: false, position: InlinePosition::Rules, marks: px, + replaced: ConsumedReferences::new(scoping), class_names: Vec::new(), + }; + inline.visit_program(program); + for (symbol, local) in declared { + let read = inline.replaced.count(symbol); + if read > 0 && read < scoping.get_resolved_reference_ids(symbol).len() { + inlined + .errors + .push((local.binding, crate::utils::local_style_error(&local.name))); + } } - .visit_program(program); } inlined } @@ -825,12 +935,34 @@ impl<'s> StyleSymbols<'s> { } } } +impl<'a> Visit<'a> for StyleSymbols<'_> { + fn visit_variable_declarator(&mut self, declarator: &oxc_ast::ast::VariableDeclarator<'a>) { + if let Some(Expression::Identifier(init)) = &declarator.init + && let Some(from) = binding_of(self.scoping, init) + && let Some(to) = declarator + .id + .get_binding_identifier() + .and_then(|identifier| identifier.symbol_id.get()) + { + for symbols in [&mut self.roots, &mut self.functions, &mut self.css] { + if symbols.contains(&from) { + symbols.insert(to); + } + } + } + walk::walk_variable_declarator(self, declarator); + } +} + /// Names read inside the props of the package's components and the arguments /// of its functions struct StyleReads<'s> { style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, names: FxHashSet, + /// Local bindings demanded by genuine style positions, separate from + /// the root names used for module lookup and diagnostics. + locals: FxHashSet, depth: usize, /// The bindings the `` child functions around take `css` and /// `cx` by @@ -848,8 +980,12 @@ impl StyleReads<'_> { impl<'a> Visit<'a> for StyleReads<'_> { fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { - if self.depth > 0 && reads_top_level(self.style.scoping, identifier) { - self.names.insert(identifier.name.to_string()); + if self.depth > 0 { + if reads_top_level(self.style.scoping, identifier) { + self.names.insert(identifier.name.to_string()); + } else if let Some(symbol) = binding_of(self.style.scoping, identifier) { + self.locals.insert(symbol); + } } } @@ -2100,6 +2236,8 @@ struct Inline<'s, 'a> { /// Where a constant stands as a value or rules of a library reading /// numbers as `px` lengths marks: &'s crate::import_alias_visit::PxMarks, + /// Semantic references erased by style folding or constant selection. + replaced: ConsumedReferences<'s>, /// The bindings the `` child functions around take `css` and /// `cx` by class_names: Vec, @@ -2199,15 +2337,20 @@ impl<'a> Inline<'_, 'a> { /// The side of a condition or of `&&`, `||` or `??` a constant or literal /// chooses, taken out of `expression` - fn chosen(&self, expression: &mut Expression<'a>) -> Option> { + fn chosen(&mut self, expression: &mut Expression<'a>) -> Option> { use oxc_allocator::TakeIn; let allocator = self.ast_builder; match expression { Expression::ConditionalExpression(conditional) => { - Some(if self.holds(&conditional.test)? { + let holds = self.holds(&conditional.test)?; + self.replaced.consume(&conditional.test); + // Only scan the erased side; the selected side is visited normally. + Some(if holds { + self.replaced.consume(&conditional.alternate); conditional.consequent.take_in(allocator) } else { + self.replaced.consume(&conditional.consequent); conditional.alternate.take_in(allocator) }) } @@ -2221,8 +2364,10 @@ impl<'a> Inline<'_, 'a> { } }; Some(if keep_left { + self.replaced.consume(&logical.right); logical.left.take_in(allocator) } else { + self.replaced.consume(&logical.left); logical.right.take_in(allocator) }) } @@ -2396,6 +2541,7 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { if let Some(literal) = self.literal(&constant) { let span = expression.span(); let marked = self.marks.contains(&(span.start, span.end)); + self.replaced.consume(expression); *expression = literal; if marked { px_number(self.ast_builder, expression); diff --git a/libs/extractor/src/imported_styled.rs b/libs/extractor/src/imported_styled.rs index e9c457607..db449342e 100644 --- a/libs/extractor/src/imported_styled.rs +++ b/libs/extractor/src/imported_styled.rs @@ -267,6 +267,7 @@ impl<'a> Reader<'_, 'a> { ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.import_css(inlined.css_styles); + visitor.errors.extend(inlined.errors); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed); visitor.takes_css_prop(css_prop); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 81881f1b2..e0fb480c6 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -637,6 +637,7 @@ fn extract_source( ); visitor.import_stylex(inlined.stylex_vars, inlined.stylex_themes); visitor.import_css(inlined.css_styles); + visitor.errors.extend(inlined.errors); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); visitor.takes_css_prop(css_prop); @@ -20256,16 +20257,14 @@ const Themed = styled('div')({ color: (p) => p.theme.brand });" ))); reset_class_map(); reset_file_map(); - assert_eq!( + assert_debug_snapshot!(ToBTreeSet::from( extract( "test.tsx", "import { styled } from '@devup-ui/react';\nconst Plain = styled('span')({ color: (p) => p.color });", ExtractOption::default(), ) - .unwrap_err() - .to_string(), - "test.tsx:2:39: `styled()` cannot use `(p) => p.color` at build time: its styles must be an object literal or a constant object, or be computed from constants" - ); + .unwrap() + )); } #[test] @@ -23899,7 +23898,9 @@ injectGlobal`body { margin: 0; }`;", "export const App = () => {({ theme }) => };", "export const App = () => {({ css, theme }) => };", "export const App = () => {({ css }) => };", + "export const App = () => {({ css }) => };", "export const App = () => {({ cx }) => };", + "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ cx }) => };", "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ css }) => };", @@ -24226,6 +24227,197 @@ export const k = (cls) => merge([cls]);" )); } + #[test] + #[serial] + fn test_style_objects_declared_in_functions() { + let devup = "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\n"; + let cases: Vec = [ + "export function f() { const inner = { color: 'red' }; return css(inner); }", + "export function f() { const a = { color: 'red' }; const b = { color: 'blue', margin: 1 }; return css(a, b); }", + "export function f(on) { const a = { color: 'red' }; return css(on && a, { margin: 1 }); }", + "export function f() { const inner = { color: 'red' }; console.log(inner); return css(inner); }", + "export function f() { const inner = { color: 'red' }; inner.color = 'blue'; return css(inner); }", + "export function f(c) { const inner = { color: c }; return css(inner); }", + "export function f() { const parts = [{ color: 'red' }, { margin: 1 }]; return css(parts); }", + "export function f() { const inner = { color: 'red' }; return css(...[inner]); }", + "export function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "export function f() { const inner = { color: 'red' }; return styled('div')(inner); }", + "export function f(c) { const inner = { color: c }; return styled.div(inner); }", + "export function f() { const inner = { body: { color: 'red' } }; return globalCss(inner); }", + "export function f(c) { const inner = { body: { color: c } }; return globalCss(inner); }", + "export function f() { const inner = { from: { opacity: 0 }, to: { opacity: 1 } }; return keyframes(inner); }", + "export function f(c) { const inner = { from: { opacity: c } }; return keyframes(inner); }", + "export function f() { const inner = { color: 'red' }; return ; }", + "export function f() { const inner = { color: 'red' }; return ; }", + "export function f() { const inner = { color: 'red' }; return ; }", + "export function f(c) { const inner = { color: c }; return ; }", + "export function f() { const inner = { color: 'red' }; const cls = 'x'; return css(inner, cls); }", + "export function f() { const inner = ({ color: ('red') }); return css(inner); }", + ] + .iter() + .map(|case| format!("{devup}{case}")) + .collect(); + let borrowed: Vec<&str> = cases.iter().map(String::as_str).collect(); + assert_debug_snapshot!(library_outputs(&borrowed, SIZE_MODULES)); + } + + #[test] + #[serial] + fn test_style_objects_declared_in_functions_of_other_libraries() { + assert_debug_snapshot!(library_outputs( + &[ + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red', padding: 8 }; return css(inner); }", + "import { css } from '@emotion/react';\nexport function f(c) { const inner = { color: c }; return css(inner); }", + "import styled from '@emotion/styled';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "import styled from '@emotion/styled';\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }", + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return css(inner); }", + "import { style } from '@vanilla-extract/css';\nexport function f() { const inner = { color: 'red' }; return style(inner); }", + "import { style } from '@vanilla-extract/css';\nexport function f(c) { const inner = { color: c }; return style(inner); }", + "import { Global } from '@emotion/react';\nexport function f() { const inner = { body: { margin: 8 } }; return ; }", + "import { ClassNames } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return {({ css }) => }; }", + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return
; }", + ], + SIZE_MODULES + )); + } + + #[test] + #[serial] + fn test_emotion_class_names_keeps_calls_as_classes() { + let import = "import { ClassNames } from '@emotion/react';\n"; + let cases = [ + "export const App = () => {({ cx }) => };", + "export const App = ({ on }) => {({ css, cx }) => };", + "export const App = ({ o }) => {({ cx }) => };", + ]; + let outputs: Vec = cases + .iter() + .map(|code| { + reset_class_map(); + reset_file_map(); + let error = + extract("test.tsx", &format!("{import}{code}"), emotion_option()).unwrap_err(); + format!("Error: {error}") + }) + .collect(); + assert_debug_snapshot!(outputs); + } + + #[test] + #[serial] + fn test_styled_functions_of_the_props_and_arrays_under_conditions() { + assert_debug_snapshot!(library_outputs( + &[ + "import styled from '@emotion/styled';\nexport const A = styled.div(function (p) { return { margin: 8, color: p.c }; });", + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme['colors'].primary, margin: theme.space[2], padding: theme.space[0] }));", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: () => 'x', margin: p.m }));", + "import styled, { css } from 'styled-components';\nexport const A = styled.div((p) => ({ color: p.color, padding: 8, lineHeight: p.lh, '&:hover': { margin: p.m } }));\nexport const B = styled('div')({ width: (p) => p.w });", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => { const x = p.x; return { color: x }; });", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ ...p.rules, color: 'red' }));", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ [p.key]: 1 }));", + "import styled from '@emotion/styled';\nexport const A = styled.div(async (p) => ({ color: p.color }));", + "import styled from '@emotion/styled';\nexport const A = styled.div`color: red;`.attrs({ id: 'x' });\nexport const B = styled.div.attrs({ id: 'x' })({ color: 'red' });\nexport const C = styled('div').attrs({ id: 'x' })({ color: 'red' });", + "import styled from '@emotion/styled';\nexport const B = (on, off) => styled.div(on ? [{ color: 'red' }, { margin: 1 }] : [{ color: 'blue' }], off && [{ width: 2 }]);", + "import styled from '@emotion/styled';\nexport const B = (on) => styled.div(on ? [{ color: 'red' }] : null, [on && [{ margin: 1 }]]);", + "import styled from '@emotion/styled';\nexport const B = (get) => styled.div(get() && [{ color: 'red' }]);", + "import { css } from '@emotion/react';\nexport const a = (on) => css(on ? [{ color: 'red' }] : [{ color: 'blue' }, { margin: 1 }], [on || [{ width: 2 }]]);", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.color, padding: 8, '&:hover': { color: (p) => p.h } });", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: ({ theme }) => theme.colors.primary, margin: ({ theme }) => theme.space[2] });", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.on ? 'red' : 'blue' });", + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [{ color: 'red' }, { margin: 1 }], { color: 'blue' });", + "import styled from '@emotion/styled';\nexport const A = styled.div(({ on }) => on && [{ color: 'red' }]);\nexport const B = (on) => styled.div(on && [{ color: 'red' }, { margin: 1 }]);", + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: 'red' }).attrs({ id: 'x' });", + "import styled from '@emotion/styled';\nexport const A = styled.div((props) => ({ color: props.color, padding: 8 }));", + "import styled from '@emotion/styled';\nexport const A = styled.div({ margin: 1 }, (props) => ({ color: props.color, width: props.w }));", + "import styled from '@emotion/styled';\nexport const A = styled.div`color: ${(p) => p.color}; padding: ${(p) => p.pad}px; background: ${({ theme }) => theme.bg}`;", + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: p.on ? 'red' : 'blue', margin: p.m, '&:hover': { color: p.h } }));", + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme.colors.primary, margin: theme.space[2] }));", + ], + SIZE_MODULES + )); + } + + #[test] + #[serial] + fn test_styled_props_rules_keep_unmapped_theme_guards() { + for rules in [ + "(p) => ({ color: p.theme.colors[key] })", + "({ theme }) => ({ color: theme.colors[key] })", + "(p) => ({ margin: p.theme.space(2) })", + "{ color: (p) => p.theme.colors[key] }", + "{ margin: ({ theme }) => theme.space(2) }", + "function (p) { return { color: p.theme.colors[key] }; }", + "{ color: function (p) { return p.theme.colors[key]; } }", + "{ margin: function ({ theme }) { return theme.space(2); } }", + "{ color: function (p) { const color = p.theme.colors.primary; return color; } }", + "{ color: async function (p) { return p.theme.colors.primary; } }", + "{ color: function* (p) { return p.theme.colors.primary; } }", + "{ color: async (p) => p.theme.colors.primary }", + "(p) => ({ color: p['theme'].colors[key] })", + "{ color: (p) => p['theme'].colors[key] }", + "({ theme: themeValue }) => ({ color: themeValue.colors[key] })", + "{ color: ({ theme: themeValue }) => themeValue.colors[key] }", + ] { + reset_class_map(); + reset_file_map(); + let code = format!( + "import styled from '@emotion/styled';\nexport const A = styled.div({rules});" + ); + let error = extract("test.tsx", &code, emotion_option()) + .unwrap_err() + .to_string(); + assert!(error.starts_with("test.tsx:2:"), "{error}"); + assert!(error.contains("a theme read must be a path"), "{error}"); + } + } + + #[test] + #[serial] + fn test_styled_renamed_theme_roots_keep_located_errors() { + for rules in [ + "({ theme: themeValue }) => ({ color: themeValue.colors[key] })", + "{ color: ({ theme: themeValue }) => themeValue.colors[key] }", + ] { + reset_class_map(); + reset_file_map(); + let source = format!( + "import styled from '@emotion/styled';\nexport const A = styled.div({rules});" + ); + let error = extract("test.tsx", &source, emotion_option()) + .unwrap_err() + .to_string(); + assert!(error.starts_with("test.tsx:2:"), "{error}"); + assert!(error.contains("a theme read must be a path"), "{error}"); + } + } + + #[test] + #[serial] + fn test_styled_function_leaves_map_literal_theme_paths() { + for rules in [ + "{ color: function (p) { return p.theme.colors.primary; }, margin: function (p) { return p.theme.space[2]; } }", + "{ color: function ({ theme }) { return theme['colors'].primary; }, margin: function ({ theme }) { return theme.space[2]; } }", + "{ color: function (p) { return p['theme'].colors.primary; }, margin: function (p) { return p['theme'].space[2]; } }", + "{ color: ({ theme: themeValue }) => themeValue.colors.primary, margin: ({ theme: themeValue }) => themeValue.space[2] }", + "{ color: function ({ theme: themeValue }) { return themeValue.colors.primary; }, margin: function ({ theme: themeValue }) { return themeValue.space[2]; } }", + ] { + reset_class_map(); + reset_file_map(); + let source = format!( + "import styled from '@emotion/styled';\nexport const A = styled.div({rules});" + ); + let output = extract("test.tsx", &source, emotion_option()).unwrap(); + for expected in ["var(--colors-primary)", "var(--space-2)"] { + assert!(output.styles.iter().any(|style| { + matches!(style, crate::ExtractStyleValue::Static(style) if style.value() == expected) + })); + } + assert!(!output.code.contains("p.theme")); + assert!(!output.code.contains("theme.space")); + } + } + #[test] #[serial] fn test_emotion_class_calls_follow_the_package_namespace_and_aliases() { diff --git a/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap index 99505d10d..a9899cabf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap @@ -5,7 +5,7 @@ expression: outputs [ "Error: /src/App.tsx:4:22: `css()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:1: `base` is changed here, so the build cannot read it as a constant", "Error: /src/App.tsx:5:18: `css()` cannot use `base.p` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/src/App.tsx:3:15: `base` is changed here, so the build cannot read it as a constant", - "Error: /src/App.tsx:4:23: `` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:5:29: `styled()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:6:22: `css()` cannot use `x ? base : null` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:7:18: Cannot compose `x ? null : [y && base]` at build time: each style must be a rule object, a class, or a condition choosing between them\n/src/App.tsx:7:22: `css()` cannot use `x ? null : [y && base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:8:22: `css()` cannot use `...[base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:10: `base` is handed here to code that may change it, so the build cannot read it as a constant", + "Error: /src/App.tsx:4:23: `` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:5:29: `styled()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:6:22: `css()` cannot use `x ? base : null` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:7:22: `css()` cannot use `x ? null : [y && base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:8:22: `css()` cannot use `...[base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:10: `base` is handed here to code that may change it, so the build cannot read it as a constant", "Error: /src/App.tsx:6:18: `css()` cannot use `baseline` at build time: its values must be literals, theme tokens or constants, or be computed from them", "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nconst hover = darken(colors.primary);\nexport const a =
;\n", "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nexport const theme = { colors };\nexport const a =
;\n", diff --git a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap index 247c9e524..2129b5cfc 100644 --- a/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap +++ b/libs/extractor/src/snapshots/extractor__tests__conditional_composition.snap @@ -117,5 +117,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--a ${`${base} ${`j ${__devupRecord0 ? \"b\" : \"\"}`}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nexport const a = `a ${cond ? \"b\" : \"c\"} ${cond ? \"d\" : \"\"}`;\nexport const b = `${flag ? \"e\" : \"f\"} ${flag ? \"\" : \"g\"}`;\nexport const c = `c ${cond ? other : \"\"} ${flag ? \"x\" : \"\"}`;\nexport const d = `i ${cond ? \"h\" : \"\"}`;\nexport const e = `${flag ? other : \"\"} ${flag ? \"c\" : \"b\"}`;\nexport const h = `${cond ? typeof cond === \"string\" ? cond : \"\" : \"\"} ${cond ? \"c\" : \"b\"}`;\nexport const i = \"c\";\nexport const j = `${value != null ? typeof value === \"string\" ? value : \"\" : \"\"} ${value != null ? \"c\" : \"b\"}`;\nexport const K = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--a ${`${base} ${`j ${__devupRecord0 ? \"b\" : \"\"}`}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap index 569e4ab3d..086197631 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_and_styled_components_object_styles.snap @@ -166,5 +166,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d f g h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--e ${`j ${__devupRecord0 ? \"k\" : \"\"}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f l\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const spin = \"m\";\nexport const App = () => <>;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport { Global } from \"@devup-ui/react/compat\";\nexport const a = \"a b c d\";\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--b e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const C = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--c f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const D = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d f g h i\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const E = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, cond);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`a--e ${`j ${__devupRecord0 ? \"k\" : \"\"}`}`, className].filter(Boolean).join(\" \")} style={style} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const F = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f l\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const spin = \"m\";\nexport const App = () => <>;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap new file mode 100644 index 000000000..b2214279c --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap @@ -0,0 +1,9 @@ +--- +source: libs/extractor/src/lib.rs +expression: outputs +--- +[ + "Error: test.tsx:2:65: `` cannot use `cx(getClass())` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "Error: test.tsx:2:76: `` cannot use `cx(css({ color: \"red\" }), on && getClass(), css({ color: \"blue\" }))` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "Error: test.tsx:2:70: `` cannot use `cx(o.getClass(), [make(1)], \"x\")` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", +] diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap index 796b3ae06..436c676f9 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_class_names_reports_what_it_cannot_compile.snap @@ -23,7 +23,9 @@ expression: errors "test.tsx:2:64: `` cannot use `theme` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", "test.tsx:2:73: `css()` cannot use `theme.space(2)` at build time: its values must be literals, theme tokens or constants, or be computed from them\ntest.tsx:2:87: `` cannot use `theme.space(2)` at build time: it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets", "test.tsx:2:60: `` cannot use `css` at build time: the `css` and `cx` its child function takes can only be called, as the build compiles each call", + "test.tsx:2:66: `` cannot use `css(getClass())` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", "test.tsx:2:65: `` cannot use `cx(getClass())` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", + "test.tsx:2:65: `` cannot use `cx(new Klass())` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", "test.tsx:2:78: `` cannot use `s` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", "test.tsx:2:75: `` cannot use `(function() { return true; })` at build time: an object `cx` takes must give each class a condition, as `{ name: condition }`", "test.tsx:2:71: `` cannot use `css(...s)` at build time: `css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these", diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap index 10b1d1f72..81a40e3b7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_flattens_inline_css_calls.snap @@ -9,15 +9,15 @@ expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport ), ( "import { css } from '@emotion/react';\nconst base = css({ color: 'green', margin: 4 });\nexport const a = (cond) => css(cond && css({ color: 'red' }), base);", - "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst base = \\\"a b\\\";\\nexport const a = (cond) => \\\"b a\\\";\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"green\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nconst base = \\\"a b\\\";\\nexport const a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn \\\"b a\\\";\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond ? css({ color: 'red' }) : null, { color: 'blue' });", - "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => \\\"a\\\";\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn \\\"a\\\";\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond || css({ color: 'red' }), { margin: 1 });", - "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => `${cond ? typeof cond === \\\"string\\\" ? cond : \\\"\\\" : \\\"\\\"} ${`b ${cond ? \\\"\\\" : \\\"a\\\"}`}`;\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn `${__devupRecord0 ? typeof __devupRecord0 === \\\"string\\\" ? __devupRecord0 : \\\"\\\" : \\\"\\\"} ${`b ${__devupRecord0 ? \\\"\\\" : \\\"a\\\"}`}`;\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = css([css({ color: 'red' }), [css({ margin: 1 }), { color: 'blue' }]]);", @@ -29,7 +29,7 @@ expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport ), ( "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [css({ color: 'red' }), { margin: 1 }], { color: 'blue' });", - "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => `b ${cond ? \\\"a\\\" : \\\"\\\"}`;\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn `b ${__devupRecord0 ? \\\"a\\\" : \\\"\\\"}`;\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = (rest) => css(css(...rest), { color: 'blue' });", @@ -37,7 +37,7 @@ expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport ), ( "import { css } from '@emotion/react';\nexport const a = (cond, cls) => css(css({ color: 'red' }), cls, cond && css({ color: 'blue' }));", - "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond, cls) => `${cls} ${cond ? \\\"a\\\" : \\\"b\\\"}`;\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond, cls) => (() => {\\n\\tconst __devupRecord0 = (0, cls);\\n\\tconst __devupRecord1 = (0, cond);\\n\\treturn `${__devupRecord0} ${__devupRecord1 ? \\\"a\\\" : \\\"b\\\"}`;\\n})();\\n\" }", ), ( "import { css } from '@emotion/react';\nexport const a = css(css({ color: 'red' }));", diff --git a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap index e30921b8e..c78c172b5 100644 --- a/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap +++ b/libs/extractor/src/snapshots/extractor__tests__emotion_css_prop_on_every_element.snap @@ -206,7 +206,7 @@ expression: "emotion_outputs(&[(\"test.tsx\",\n\"const Custom = ({ className }) }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Wide = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nconst Toggle = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, on);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[__devupRecord0 ? \"b\" : \"c\", className].filter(Boolean).join(\" \")} style={style} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = ({ rest, c }) => <>
;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Wide = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nconst Toggle = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, on);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[__devupRecord0 ? \"b\" : \"c\", className].filter(Boolean).join(\" \")} style={style} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const App = ({ rest, c }) => <>
;\n", }, ), ( diff --git a/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap index a5f3b5f6c..e0db47b5f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap +++ b/libs/extractor/src/snapshots/extractor__tests__layer_record_places_every_declaration.snap @@ -156,5 +156,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, cond);\n\tconst __devupRecord1 = (0, pos);\n\tconst __devupRecord2 = (0, {\n\t\t__proto__: null,\n\t\t[\"a\"]: 0,\n\t\t[\"b\"]: 1\n\t}[key]);\n\tconst __devupRecord3 = (0, typo);\n\tconst __devupRecord4 = (0, w);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`i k l m ${__devupRecord0 ? \"a\" : \"b\"} ${{\n\t\t\"bottom\": \"c\",\n\t\t\"bottom-left\": \"c d\",\n\t\t\"bottom-right\": \"c e\",\n\t\t\"left\": \"d\",\n\t\t\"right\": \"e\",\n\t\t\"top\": \"f\",\n\t\t\"top-left\": \"d f\",\n\t\t\"top-right\": \"e f\"\n\t}[__devupRecord1] || \"\"} ${{\n\t\t\"0\": \"g\",\n\t\t\"1\": \"h\"\n\t}[__devupRecord2] || \"\"} ${__devupRecord3 ? `typo-${__devupRecord3}` : \"\"}`, className].filter(Boolean).join(\" \")} style={{\n\t\t...{ \"--j\": __devupRecord4 },\n\t\t...style\n\t}} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, cond);\n\tconst __devupRecord1 = (0, pos);\n\tconst __devupRecord2 = (0, {\n\t\t__proto__: null,\n\t\t[\"a\"]: 0,\n\t\t[\"b\"]: 1\n\t}[key]);\n\tconst __devupRecord3 = (0, typo);\n\tconst __devupRecord4 = (0, w);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[`i k l m ${__devupRecord0 ? \"a\" : \"b\"} ${{\n\t\t\"bottom\": \"c\",\n\t\t\"bottom-left\": \"c d\",\n\t\t\"bottom-right\": \"c e\",\n\t\t\"left\": \"d\",\n\t\t\"right\": \"e\",\n\t\t\"top\": \"f\",\n\t\t\"top-left\": \"d f\",\n\t\t\"top-right\": \"e f\"\n\t}[__devupRecord1] || \"\"} ${{\n\t\t\"0\": \"g\",\n\t\t\"1\": \"h\"\n\t}[__devupRecord2] || \"\"} ${__devupRecord3 ? `typo-${__devupRecord3}` : \"\"}`, className].filter(Boolean).join(\" \")} style={{\n\t\t...{ \"--j\": __devupRecord4 },\n\t\t...style\n\t}} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap new file mode 100644 index 000000000..adc0287c3 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap @@ -0,0 +1,90 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&borrowed, SIZE_MODULES)" +--- +[ + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const a = { color: 'red' }; const b = { color: 'blue', margin: 1 }; return css(a, b); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst a = { color: \\\"red\\\" };\\n\\tconst b = {\\n\\t\\tcolor: \\\"blue\\\",\\n\\t\\tmargin: 1\\n\\t};\\n\\treturn \\\"a b\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(on) { const a = { color: 'red' }; return css(on && a, { margin: 1 }); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f(on) {\\n\\tconst a = { color: \\\"red\\\" };\\n\\treturn `b ${on ? \\\"a\\\" : \\\"\\\"}`;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; console.log(inner); return css(inner); }", + "Error: /src/App.tsx:2:29: Cannot read `inner` as styles at build time: it is also read elsewhere, where code may change it, so use it only as styles, or declare it with `const` at the top level of the module", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; inner.color = 'blue'; return css(inner); }", + "Error: /src/App.tsx:2:29: Cannot read `inner` as styles at build time: it is also read elsewhere, where code may change it, so use it only as styles, or declare it with `const` at the top level of the module", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { color: c }; return css(inner); }", + "Error: /src/App.tsx:2:63: `css()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const parts = [{ color: 'red' }, { margin: 1 }]; return css(parts); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"4px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst parts = [{ color: \\\"red\\\" }, { margin: 1 }];\\n\\treturn \\\"a b\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return css(...[inner]); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return styled('div')(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }", + "Error: /src/App.tsx:2:70: `styled()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { body: { color: 'red' } }; return globalCss(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: Some(Global(\"body\", \"/src/App.tsx\")), style_order: Some(0), layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { body: { color: \\\"red\\\" } };\\n\\treturn void 0;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { body: { color: c } }; return globalCss(inner); }", + "Error: /src/App.tsx:2:79: `globalCss()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it\n/src/App.tsx:2:79: `globalCss()` cannot use `inner` at build time: its values must be literals, theme tokens or constants, or be computed from them", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { from: { opacity: 0 }, to: { opacity: 1 } }; return keyframes(inner); }", + "ToBTreeSet { styles: {Keyframes(ExtractKeyframes { keyframes: {\"from\": [ExtractStaticStyle { property: \"opacity\", value: \"0\", level: 0, selector: None, style_order: None, layer: None }], \"to\": [ExtractStaticStyle { property: \"opacity\", value: \"1\", level: 0, selector: None, style_order: None, layer: None }]} })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = {\\n\\t\\tfrom: { opacity: 0 },\\n\\t\\tto: { opacity: 1 }\\n\\t};\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { from: { opacity: c } }; return keyframes(inner); }", + "Error: /src/App.tsx:2:81: `keyframes()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it\n/src/App.tsx:2:81: `keyframes()` cannot use `inner` at build time: its values must be literals, theme tokens or constants, or be computed from them", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: Some(Selector(\"&:hover\")), style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f(c) { const inner = { color: c }; return ; }", + "ToBTreeSet { styles: {}, code: \"export function f(c) {\\n\\tconst inner = { color: c };\\n\\treturn
;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = { color: 'red' }; const cls = 'x'; return css(inner, cls); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\tconst cls = \\\"x\\\";\\n\\treturn `a ${cls}`;\\n}\\n\" }", + ), + ( + "import { css, styled, globalCss, keyframes, Box } from '@devup-ui/react';\nexport function f() { const inner = ({ color: ('red') }); return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap new file mode 100644 index 000000000..1ba1c0830 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap @@ -0,0 +1,50 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import { css } from '@emotion/react';\\nexport function f() { const inner = { color: 'red', padding: 8 }; return css(inner); }\",\n\"import { css } from '@emotion/react';\\nexport function f(c) { const inner = { color: c }; return css(inner); }\",\n\"import styled from '@emotion/styled';\\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }\",\n\"import styled from '@emotion/styled';\\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }\",\n\"import styled, { css } from 'styled-components';\\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }\",\n\"import styled, { css } from 'styled-components';\\nexport function f() { const inner = { color: 'red' }; return css(inner); }\",\n\"import { style } from '@vanilla-extract/css';\\nexport function f() { const inner = { color: 'red' }; return style(inner); }\",\n\"import { style } from '@vanilla-extract/css';\\nexport function f(c) { const inner = { color: c }; return style(inner); }\",\n\"import { Global } from '@emotion/react';\\nexport function f() { const inner = { body: { margin: 8 } }; return ; }\",\n\"import { ClassNames } from '@emotion/react';\\nexport function f() { const inner = { color: 'red' }; return {({ css }) => }; }\",\n\"import { css } from '@emotion/react';\\nexport function f() { const inner = { color: 'red' }; return
; }\",],\nSIZE_MODULES)" +--- +[ + ( + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red', padding: 8 }; return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = {\\n\\t\\tcolor: \\\"red\\\",\\n\\t\\tpadding: 8\\n\\t};\\n\\treturn \\\"a b\\\";\\n}\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport function f(c) { const inner = { color: c }; return css(inner); }", + "Error: /src/App.tsx:2:63: `css()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import styled from '@emotion/styled';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport function f(c) { const inner = { color: c }; return styled.div(inner); }", + "Error: /src/App.tsx:2:70: `styled()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return styled.div(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t\\t...__devupRefProps,\\n\\t\\tref: __devupRef\\n\\t}));\\n}\\n\" }", + ), + ( + "import styled, { css } from 'styled-components';\nexport function f() { const inner = { color: 'red' }; return css(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { style } from '@vanilla-extract/css';\nexport function f() { const inner = { color: 'red' }; return style(inner); }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn \\\"a\\\";\\n}\\n\" }", + ), + ( + "import { style } from '@vanilla-extract/css';\nexport function f(c) { const inner = { color: c }; return style(inner); }", + "Error: /src/App.tsx:2:65: `css()` cannot use `inner` at build time: a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it", + ), + ( + "import { Global } from '@emotion/react';\nexport function f() { const inner = { body: { margin: 8 } }; return ; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: Some(Global(\"body\", \"/src/App.tsx\")), style_order: Some(0), layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { Global } from \\\"@devup-ui/react/compat\\\";\\nexport function f() {\\n\\tconst inner = { body: { margin: 8 } };\\n\\treturn ;\\n}\\n\" }", + ), + ( + "import { ClassNames } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return {({ css }) => }; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn ;\\n}\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport function f() { const inner = { color: 'red' }; return
; }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport function f() {\\n\\tconst inner = { color: \\\"red\\\" };\\n\\treturn
;\\n}\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap b/libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap new file mode 100644 index 000000000..5be47dddb --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap @@ -0,0 +1,102 @@ +--- +source: libs/extractor/src/lib.rs +expression: "library_outputs(&[\"import styled from '@emotion/styled';\\nexport const A = styled.div(function (p) { return { margin: 8, color: p.c }; });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(({ theme }) => ({ color: theme['colors'].primary, margin: theme.space[2], padding: theme.space[0] }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ color: () => 'x', margin: p.m }));\",\n\"import styled, { css } from 'styled-components';\\nexport const A = styled.div((p) => ({ color: p.color, padding: 8, lineHeight: p.lh, '&:hover': { margin: p.m } }));\\nexport const B = styled('div')({ width: (p) => p.w });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => { const x = p.x; return { color: x }; });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ ...p.rules, color: 'red' }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ [p.key]: 1 }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(async (p) => ({ color: p.color }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div`color: red;`.attrs({ id: 'x' });\\nexport const B = styled.div.attrs({ id: 'x' })({ color: 'red' });\\nexport const C = styled('div').attrs({ id: 'x' })({ color: 'red' });\",\n\"import styled from '@emotion/styled';\\nexport const B = (on, off) => styled.div(on ? [{ color: 'red' }, { margin: 1 }] : [{ color: 'blue' }], off && [{ width: 2 }]);\",\n\"import styled from '@emotion/styled';\\nexport const B = (on) => styled.div(on ? [{ color: 'red' }] : null, [on && [{ margin: 1 }]]);\",\n\"import styled from '@emotion/styled';\\nexport const B = (get) => styled.div(get() && [{ color: 'red' }]);\",\n\"import { css } from '@emotion/react';\\nexport const a = (on) => css(on ? [{ color: 'red' }] : [{ color: 'blue' }, { margin: 1 }], [on || [{ width: 2 }]]);\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: (p) => p.color, padding: 8, '&:hover': { color: (p) => p.h } });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: ({ theme }) => theme.colors.primary, margin: ({ theme }) => theme.space[2] });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: (p) => p.on ? 'red' : 'blue' });\",\n\"import { css } from '@emotion/react';\\nexport const a = (cond) => css(cond && [{ color: 'red' }, { margin: 1 }], { color: 'blue' });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(({ on }) => on && [{ color: 'red' }]);\\nexport const B = (on) => styled.div(on && [{ color: 'red' }, { margin: 1 }]);\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ color: 'red' }).attrs({ id: 'x' });\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((props) => ({ color: props.color, padding: 8 }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div({ margin: 1 }, (props) => ({ color: props.color, width: props.w }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div`color: ${(p) => p.color}; padding: ${(p) => p.pad}px; background: ${({ theme }) => theme.bg}`;\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div((p) => ({ color: p.on ? 'red' : 'blue', margin: p.m, '&:hover': { color: p.h } }));\",\n\"import styled from '@emotion/styled';\\nexport const A = styled.div(({ theme }) => ({ color: theme.colors.primary, margin: theme.space[2] }));\",],\nSIZE_MODULES)" +--- +[ + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(function (p) { return { margin: 8, color: p.c }; });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord1\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, function(p) {\\n\\t\\treturn [p.c];\\n\\t}(rest));\\n\\tconst __devupRecord1 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"0\\\"]));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a b\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{ \\\"--c\\\": __devupRecord1 },\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme['colors'].primary, margin: theme.space[2], padding: theme.space[0] }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"var(--colors-primary)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"var(--space-2)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"padding\", value: \"var(--space-0)\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, (({ theme }) => [])(rest));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a b c\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: () => 'x', margin: p.m }));", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord1\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"__devupRecord2\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((p) => [() => \\\"x\\\", p.m])(rest));\\n\\tconst __devupRecord1 = (0, (0, __devupRecord0[\\\"0\\\"])(rest));\\n\\tconst __devupRecord2 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"1\\\"]));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--b\\\": __devupRecord1,\\n\\t\\t\\t\\\"--d\\\": __devupRecord2\\n\\t\\t},\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled, { css } from 'styled-components';\nexport const A = styled.div((p) => ({ color: p.color, padding: 8, lineHeight: p.lh, '&:hover': { margin: p.m } }));\nexport const B = styled('div')({ width: (p) => p.w });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord1\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"line-height\", level: 0, identifier: \"__devupRecord2\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"__devupRecord3\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"__devupRecord0\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((p) => [\\n\\t\\tp.color,\\n\\t\\tp.lh,\\n\\t\\tp.m\\n\\t])(rest));\\n\\tconst __devupRecord1 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"0\\\"]));\\n\\tconst __devupRecord2 = (0, __devupRecord0[\\\"1\\\"]);\\n\\tconst __devupRecord3 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"2\\\"]));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a c d f\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--b\\\": __devupRecord1,\\n\\t\\t\\t\\\"--e\\\": __devupRecord2,\\n\\t\\t\\t\\\"--g\\\": __devupRecord3\\n\\t\\t},\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((p) => p.w)(rest));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--b h\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{ \\\"--i\\\": __devupRecord0 },\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => { const x = p.x; return { color: x }; });", + "Error: /src/App.tsx:2:29: `styled()` cannot use `(p) => { const x = p.x; return { color: x }; }` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ ...p.rules, color: 'red' }));", + "Error: /src/App.tsx:2:29: `styled()` cannot use `(p) => ({ ...p.rules, color: \"red\" })` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ [p.key]: 1 }));", + "Error: /src/App.tsx:2:29: `styled()` cannot use `(p) => ({ [p.key]: 1 })` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(async (p) => ({ color: p.color }));", + "Error: /src/App.tsx:2:29: `styled()` cannot use `async (p) => ({ color: p.color })` at build time: a style function of the props must give one rule object at once, with every entry written out, as `(props) => ({ color: props.color })`", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div`color: red;`.attrs({ id: 'x' });\nexport const B = styled.div.attrs({ id: 'x' })({ color: 'red' });\nexport const C = styled('div').attrs({ id: 'x' })({ color: 'red' });", + "Error: /src/App.tsx:2:18: `styled()` cannot use `styled.div`color: red;`.attrs({ id: \"x\" })` at build time: chain `.attrs` before the styles, as `styled.div.attrs({ ... })({ ... })`, which neither Emotion nor styled-components take after them", + ), + ( + "import styled from '@emotion/styled';\nexport const B = (on, off) => styled.div(on ? [{ color: 'red' }, { margin: 1 }] : [{ color: 'blue' }], off && [{ width: 2 }]);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"width\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const B = (on, off) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, on);\\n\\tconst __devupRecord1 = (0, off);\\n\\tconst __devupRecord2 = (0, __devupRecord0);\\n\\tconst __devupRecord3 = (0, __devupRecord0);\\n\\tconst __devupRecord4 = (0, __devupRecord3 ? __devupRecord0 : undefined);\\n\\tconst __devupRecord5 = (0, __devupRecord1);\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[`${__devupRecord2 ? \\\"a\\\" : \\\"\\\"} ${__devupRecord3 ? __devupRecord4 ? \\\"b\\\" : \\\"\\\" : \\\"c\\\"} ${__devupRecord5 ? \\\"d\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const B = (on) => styled.div(on ? [{ color: 'red' }] : null, [on && [{ margin: 1 }]]);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const B = (on) => __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, on);\\n\\tconst __devupRecord1 = (0, on);\\n\\tconst __devupRecord2 = (0, __devupRecord0);\\n\\tconst __devupRecord3 = (0, __devupRecord1);\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[`${__devupRecord2 ? \\\"a\\\" : \\\"\\\"} ${__devupRecord3 ? \\\"b\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const B = (get) => styled.div(get() && [{ color: 'red' }]);", + "Error: /src/App.tsx:2:27: Cannot compose `get() && [{ color: \"red\" }]` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (on) => css(on ? [{ color: 'red' }] : [{ color: 'blue' }, { margin: 1 }], [on || [{ width: 2 }]]);", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"color\", value: \"red\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"width\", value: \"2px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (on) => (() => {\\n\\tconst __devupRecord0 = (0, on);\\n\\tconst __devupRecord1 = (0, on);\\n\\treturn `${__devupRecord1 ? typeof __devupRecord1 === \\\"string\\\" ? __devupRecord1 : \\\"\\\" : \\\"\\\"} ${`${__devupRecord0 ? \\\"a\\\" : \\\"b\\\"} ${__devupRecord0 ? \\\"\\\" : \\\"c\\\"} ${__devupRecord1 ? \\\"\\\" : \\\"d\\\"}`}`;\\n})();\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.color, padding: 8, '&:hover': { color: (p) => p.h } });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord0\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord1\", selector: Some(Selector(\"&:hover\")), style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((p) => p.color)(rest));\\n\\tconst __devupRecord1 = (0, ((p) => p.h)(rest));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a c d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--b\\\": __devupRecord0,\\n\\t\\t\\t\\\"--e\\\": __devupRecord1\\n\\t\\t},\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: ({ theme }) => theme.colors.primary, margin: ({ theme }) => theme.space[2] });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"var(--colors-primary)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"var(--space-2)\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a b\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: (p) => p.on ? 'red' : 'blue' });", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord0\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((p) => p.on ? \\\"red\\\" : \\\"blue\\\")(rest));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{ \\\"--b\\\": __devupRecord0 },\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import { css } from '@emotion/react';\nexport const a = (cond) => css(cond && [{ color: 'red' }, { margin: 1 }], { color: 'blue' });", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"blue\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nexport const a = (cond) => (() => {\\n\\tconst __devupRecord0 = (0, cond);\\n\\treturn `b ${__devupRecord0 ? \\\"a\\\" : \\\"\\\"}`;\\n})();\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(({ on }) => on && [{ color: 'red' }]);\nexport const B = (on) => styled.div(on && [{ color: 'red' }, { margin: 1 }]);", + "Error: /src/App.tsx:2:18: Cannot compose `({ on }) => on && [{ color: \"red\" }]` at build time: each style must be a rule object, a class, or a condition choosing between them", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ color: 'red' }).attrs({ id: 'x' });", + "Error: /src/App.tsx:2:18: `styled()` cannot use `styled.div({ color: \"red\" }).attrs({ id: \"x\" })` at build time: chain `.attrs` before the styles, as `styled.div.attrs({ ... })({ ... })`, which neither Emotion nor styled-components take after them", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((props) => ({ color: props.color, padding: 8 }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord1\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((props) => [props.color])(rest));\\n\\tconst __devupRecord1 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"0\\\"]));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{ \\\"--b\\\": __devupRecord1 },\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div({ margin: 1 }, (props) => ({ color: props.color, width: props.w }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord1\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"__devupRecord2\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((props) => [props.color, props.w])(rest));\\n\\tconst __devupRecord1 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"0\\\"]));\\n\\tconst __devupRecord2 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"1\\\"]));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a b d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--c\\\": __devupRecord1,\\n\\t\\t\\t\\\"--e\\\": __devupRecord2\\n\\t\\t},\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div`color: ${(p) => p.color}; padding: ${(p) => p.pad}px; background: ${({ theme }) => theme.bg}`;", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"background\", value: \"var(--bg)\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>p.color)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"padding\", level: 0, identifier: \"`${(p=>p.pad)(rest)}px`\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a c e\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": `${(p=>p.pad)(rest)}px`,\\n\\t\\t\\\"--d\\\": (p=>p.color)(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div((p) => ({ color: p.on ? 'red' : 'blue', margin: p.m, '&:hover': { color: p.h } }));", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord1\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"__devupRecord3\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"__devupRecord2\", selector: None, style_order: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, ((p) => [\\n\\t\\tp.on ? \\\"red\\\" : \\\"blue\\\",\\n\\t\\tp.m,\\n\\t\\tp.h\\n\\t])(rest));\\n\\tconst __devupRecord1 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"0\\\"]));\\n\\tconst __devupRecord2 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"1\\\"]));\\n\\tconst __devupRecord3 = (0, ((__devupValue) => typeof __devupValue === \\\"number\\\" && __devupValue !== 0 ? __devupValue + \\\"px\\\" : __devupValue)(__devupRecord0[\\\"2\\\"]));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a c e\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--b\\\": __devupRecord1,\\n\\t\\t\\t\\\"--d\\\": __devupRecord2,\\n\\t\\t\\t\\\"--f\\\": __devupRecord3\\n\\t\\t},\\n\\t\\t...style\\n\\t}} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), + ( + "import styled from '@emotion/styled';\nexport const A = styled.div(({ theme }) => ({ color: theme.colors.primary, margin: theme.space[2] }));", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"color\", value: \"var(--colors-primary)\", level: 0, selector: None, style_order: None, layer: None }), Static(ExtractStaticStyle { property: \"margin\", value: \"var(--space-2)\", level: 0, selector: None, style_order: None, layer: None })}, code: \"import \\\"@devup-ui/react/devup-ui.css\\\";\\nimport { forwardRef as __devupForwardRef } from \\\"react\\\";\\nexport const A = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => {\\n\\tconst __devupRecord0 = (0, (({ theme }) => [])(rest));\\n\\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \\\"$\\\"))} as={forwardedAs} className={[\\\"a--a a b\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />;\\n})({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + ), +] diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap b/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap new file mode 100644 index 000000000..0e7957e4a --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap @@ -0,0 +1,18 @@ +--- +source: libs/extractor/src/lib.rs +expression: "ToBTreeSet::from(extract(\"test.tsx\",\n\"import { styled } from '@devup-ui/react';\\nconst Plain = styled('span')({ color: (p) => p.color });\",\nExtractOption::default(),).unwrap())" +--- +ToBTreeSet { + styles: { + Dynamic( + ExtractDynamicStyle { + property: "color", + level: 0, + identifier: "__devupRecord0", + selector: None, + style_order: None, + }, + ), + }, + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst Plain = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, ((p) => p.color)(rest));\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{ \"--a\": __devupRecord0 },\n\t\t...style\n\t}} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", +} diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-3.snap b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-3.snap index 0991c8356..8866a23ed 100644 --- a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-3.snap @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nlet primaryColor = \"blue\";\nlet padding = \"16px\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, primaryColor);\n\tconst __devupRecord1 = (0, padding);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1\n\t\t},\n\t\t...style\n\t}} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nlet primaryColor = \"blue\";\nlet padding = \"16px\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, primaryColor);\n\tconst __devupRecord1 = (0, padding);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1\n\t\t},\n\t\t...style\n\t}} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-4.snap b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-4.snap index 8e17e8a75..7de3f5bd4 100644 --- a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-4.snap @@ -23,5 +23,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nlet primaryColor = \"blue\";\nlet padding = \"16px\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, primaryColor);\n\tconst __devupRecord1 = (0, padding);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1\n\t\t},\n\t\t...style\n\t}} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nlet primaryColor = \"blue\";\nlet padding = \"16px\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, primaryColor);\n\tconst __devupRecord1 = (0, padding);\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1\n\t\t},\n\t\t...style\n\t}} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-6.snap b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-6.snap index 6c95ea062..0998e2c4e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-6.snap @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, obj.bg);\n\tconst __devupRecord1 = (0, func());\n\tconst __devupRecord2 = (0, obj.color());\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b a-c\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1,\n\t\t\t\"--c\": __devupRecord2\n\t\t},\n\t\t...style\n\t}} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, obj.bg);\n\tconst __devupRecord1 = (0, func());\n\tconst __devupRecord2 = (0, obj.color());\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b a-c\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1,\n\t\t\t\"--c\": __devupRecord2\n\t\t},\n\t\t...style\n\t}} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-7.snap b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-7.snap index 9969e33d5..027bf874b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-7.snap +++ b/libs/extractor/src/snapshots/extractor__tests__styled_with_variable_like_emotion-7.snap @@ -32,5 +32,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => (() => {\n\tconst __devupRecord0 = (0, obj.bg);\n\tconst __devupRecord1 = (0, func());\n\tconst __devupRecord2 = (0, obj.color());\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b a-c\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1,\n\t\t\t\"--c\": __devupRecord2\n\t\t},\n\t\t...style\n\t}} />;\n})())({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nconst StyledDiv = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => {\n\tconst __devupRecord0 = (0, obj.bg);\n\tconst __devupRecord1 = (0, func());\n\tconst __devupRecord2 = (0, obj.color());\n\treturn __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a-a a-b a-c\", className].filter(Boolean).join(\" \")} style={{\n\t\t...{\n\t\t\t\"--a\": __devupRecord0,\n\t\t\t\"--b\": __devupRecord1,\n\t\t\t\"--c\": __devupRecord2\n\t\t},\n\t\t...style\n\t}} />;\n})({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", } diff --git a/libs/extractor/src/styled_reads.rs b/libs/extractor/src/styled_reads.rs index 2f8033dfd..fbaac98e7 100644 --- a/libs/extractor/src/styled_reads.rs +++ b/libs/extractor/src/styled_reads.rs @@ -30,11 +30,21 @@ impl Reads { pub fn read_in(&mut self, expression: &Expression<'_>) { match unwrap_syntax_only(expression) { Expression::ArrowFunctionExpression(arrow) => self.function(&arrow.params, |v| { + v.visit_formal_parameters(&arrow.params); v.visit_arrow_function_body(arrow); }), - Expression::FunctionExpression(function) => self.function(&function.params, |v| { - v.visit_function_body_of(function); - }), + Expression::FunctionExpression(function) => { + let mut arguments = ArgumentsUse(false); + arguments.visit_formal_parameters(&function.params); + if let Some(body) = &function.body { + arguments.visit_function_body(body); + } + self.whole |= arguments.0; + self.function(&function.params, |v| { + v.visit_formal_parameters(&function.params); + v.visit_function_body_of(function); + }); + } Expression::ObjectExpression(object) => { for property in &object.properties { match property { @@ -60,6 +70,7 @@ impl Reads { fn function(&mut self, params: &FormalParameters<'_>, body: impl FnOnce(&mut PropsUse<'_>)) { let Some(first) = params.items.first() else { + self.whole |= params.rest.is_some(); return; }; match &first.pattern { @@ -86,6 +97,16 @@ impl Reads { } } +struct ArgumentsUse(bool); + +impl<'a> Visit<'a> for ArgumentsUse { + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + self.0 |= it.name == "arguments"; + } + + fn visit_function(&mut self, _: &Function<'a>, _: oxc_syntax::scope::ScopeFlags) {} +} + struct PropsUse<'r> { name: &'r str, reads: &'r mut Reads, diff --git a/libs/extractor/src/styled_source_tests/controller_evaluation.rs b/libs/extractor/src/styled_source_tests/controller_evaluation.rs new file mode 100644 index 000000000..8fd6b0b86 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/controller_evaluation.rs @@ -0,0 +1,169 @@ +use super::execute_in_mode; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::emotion("emotionCss", false, "1px")] +#[case::native("css", false, "4px")] +#[case::styled("styled.div", true, "4px")] +#[serial] +fn authored_controllers_keep_distinct_reads_between_opaque_classes( + #[case] factory: &str, + #[case] styled: bool, + #[case] margin: &str, + #[values(false, true)] single_css: bool, +) { + // Given + let source = format!( + "import {{css as emotionCss}} from '@emotion/react';const trace=[];let n=0;const state={{get before(){{trace.push('before');return 'before';}},get on(){{trace.push('read');return ++n===1;}},get middle(){{trace.push('middle');return 'middle';}},get after(){{trace.push('after');return 'after';}}}};const f=input=>{factory}(input.before,input.on?[{{color:'red'}},{{margin:1}}]:[{{color:'blue'}}],input.middle,input.on?[{{background:'green'}},{{padding:1}}]:[{{background:'black'}}],input.after);" + ); + let call = if styled { + "render({target:f(state),props:{}})" + } else { + "({target:'div',props:{className:f(state)}})" + }; + let observation = format!( + "const first={call};JSON.stringify([trace,values(first,'color'),values(first,'margin'),values(first,'background'),values(first,'padding'),['before','middle','after'].filter(name=>first.props.className.split(/\\s+/).includes(name))]);" + ); + // When + let observed = execute_in_mode("test.ts", &source, &observation, single_css); + // Then + assert_eq!( + observed, + serde_json::json!([ + ["before", "read", "middle", "read", "after"], + ["red"], + [margin], + ["black"], + [], + ["before", "middle", "after"] + ]) + ); +} + +#[rstest] +#[serial] +fn styled_controllers_stay_per_render_with_short_circuit_guards( + #[values(false, true)] single_css: bool, +) { + // Given + let source = "const trace=[];let selected=true;const state={get outer(){trace.push('outer');return selected;},get inner(){trace.push('inner');return true;}};const Card=styled.div(state.outer&&state.inner?[{color:'red'},{margin:1}]:[{color:'blue'}]);const defined=trace.slice();"; + let observation = "const first=render({target:Card,props:{}});const firstTrace=trace.slice();selected=false;const second=render({target:Card,props:{}});JSON.stringify([defined,firstTrace,trace,values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin')]);"; + // When + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then + assert_eq!( + observed, + serde_json::json!([ + [], + ["outer", "inner"], + ["outer", "inner", "outer"], + ["red"], + ["4px"], + ["blue"], + [] + ]) + ); +} + +#[rstest] +#[serial] +fn styled_leaf_reads_keep_authored_order_even_when_later_rules_cover_them( + #[values(false, true)] single_css: bool, +) { + // Given + let source = "const trace=[];const state={get before(){trace.push('before');return 'green';},get on(){trace.push('on');return true;},get margin(){trace.push('margin');return '4px';},get after(){trace.push('after');return 0.5;}};const Card=styled.div([{color:state.before},state.on?[{margin:state.margin},{color:'red'}]:[{color:'blue'}],{opacity:state.after}]);"; + let observation = "const first=render({target:Card,props:{}});JSON.stringify([trace,values(first,'color'),values(first,'margin'),values(first,'opacity')]);"; + // When + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then + assert_eq!( + observed, + serde_json::json!([["before", "on", "margin", "after"], ["red"], ["4px"], [0.5]]) + ); +} + +#[rstest] +#[case::emotion("emotionCss", "1px")] +#[case::native("css", "4px")] +#[serial] +fn conditional_lookup_atoms_keep_guarded_selector_reads( + #[case] factory: &str, + #[case] margin: &str, + #[values(false, true)] single_css: bool, +) { + // Given + let source = format!( + "import {{css as emotionCss}} from '@emotion/react';const trace=[];let selected=true;const state={{get on(){{trace.push('on');return selected;}},get key(){{trace.push('key');return 'red';}}}};const f=()=>{factory}(state.on?[{{color:{{red:'red',blue:'blue'}}[state.key]}},{{margin:1}}]:[{{color:'black'}}]);" + ); + let observation = "const first={props:{className:f()}};const firstTrace=trace.slice();selected=false;const second={props:{className:f()}};JSON.stringify([firstTrace,trace,values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin')]);"; + // When + let observed = execute_in_mode("test.ts", &source, observation, single_css); + // Then + assert_eq!( + observed, + serde_json::json!([ + ["on", "key"], + ["on", "key", "on"], + ["red"], + [margin], + ["black"], + [] + ]) + ); +} + +#[rstest] +#[case::emotion("emotionCss", false)] +#[case::native("css", false)] +#[case::styled("styled.div", true)] +#[serial] +fn empty_rule_arrays_still_evaluate_the_authored_controller( + #[case] factory: &str, + #[case] styled: bool, + #[values(false, true)] single_css: bool, +) { + // Given + let source = format!( + "import {{css as emotionCss}} from '@emotion/react';const trace=[];const state={{get on(){{trace.push('read');return true;}}}};const f=()=>{factory}(state.on?[]:[]);" + ); + let observation = if styled { + "render({target:f(),props:{}});JSON.stringify(trace);" + } else { + "f();JSON.stringify(trace);" + }; + // When + let observed = execute_in_mode("test.ts", &source, observation, single_css); + // Then + assert_eq!(observed, serde_json::json!(["read"])); +} + +#[rstest] +#[serial] +fn styled_extensions_rebind_captured_classes_with_their_controllers( + #[values(false, true)] single_css: bool, +) { + // Given + let source = "const trace=[];let n=0;const state={get opaque(){trace.push('class');return 'opaque';},get on(){trace.push('on');return ++n===1;}};const f=input=>{const Base=styled.div(input.opaque,input.on?[{color:'red'},{margin:1}]:[{color:'blue'}]);const With=Base.withComponent('section');const Child=styled(Base)({padding:1});return {With,Child};};const {With,Child}=f(state);const defined=trace.slice();"; + let observation = "const first=render({target:With,props:{}});const second=render({target:Child,props:{}});JSON.stringify([defined,trace,first.target,values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin'),values(second,'padding'),[first,second].map(element=>element.props.className.split(/\\s+/).includes('opaque'))]);"; + // When + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then + assert_eq!( + observed, + serde_json::json!([ + [], + ["class", "on", "class", "on"], + "section", + ["red"], + ["4px"], + ["blue"], + [], + ["4px"], + [true, true] + ]) + ); +} + +#[path = "controller_evaluation_refusals.rs"] +mod refusals; diff --git a/libs/extractor/src/styled_source_tests/controller_evaluation_refusals.rs b/libs/extractor/src/styled_source_tests/controller_evaluation_refusals.rs new file mode 100644 index 000000000..2632760c1 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/controller_evaluation_refusals.rs @@ -0,0 +1,69 @@ +use crate::{ExtractOption, ImportAlias, extract, utils::STYLE_OBJECT}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::array("[state.before,state.on?[{color:'red'},{margin:1}]:[],state.after]")] +#[case::comma("state.before,state.on?[{color:'red'},{margin:1}]:[],state.after")] +#[case::distinct_array( + "[state.before,state.on?[{color:'red'},{margin:1}]:[{color:'blue'}],state.middle,state.on?[{background:'green'},{padding:1}]:[{background:'black'}],state.after]" +)] +#[case::lookup_array( + "[state.on?[{color:{red:'red',blue:'blue'}[state.key]},{margin:1}]:[],state.after]" +)] +#[serial] +fn module_getter_class_shapes_remain_refused( + #[case] arguments: &str, + #[values("emotionCss", "css", "styled.div")] factory: &str, + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css,styled}} from '@devup-ui/react';import {{css as emotionCss}} from '@emotion/react';const trace=[];let n=0;let selectedKey='red';const key={{[Symbol.toPrimitive](){{trace.push('coerce');return selectedKey;}}}};const state={{get before(){{trace.push('before');return 'before';}},get on(){{trace.push('on');return ++n===1;}},get middle(){{trace.push('middle');return 'middle';}},get key(){{trace.push('key');return key;}},get after(){{trace.push('after');selectedKey='blue';return 'opaque';}}}};const f=()=>{factory}({arguments});" + ); + let option = ExtractOption { + single_css, + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + // When + let result = extract("test.ts", &source, option); + // Then + let error = match result { + Ok(output) => panic!("module getter classes must remain refused: {output:?}"), + Err(error) => error, + }; + assert!(error.to_string().contains(STYLE_OBJECT), "{error}"); +} + +#[rstest] +#[serial] +fn module_getter_array_styled_retarget_shape_remains_refused( + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled} from '@devup-ui/react';const trace=[];let n=0;const state={get opaque(){trace.push('class');return 'opaque';},get on(){trace.push('on');return ++n===1;}};const Base=styled.div([state.opaque,state.on?[{color:'red'},{margin:1}]:[{color:'blue'}]]);const With=Base.withComponent('section');const Child=styled(Base)({padding:1});"; + // When + let result = extract( + "test.ts", + source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then + let error = match result { + Ok(output) => panic!("module getter array retarget must remain refused: {output:?}"), + Err(error) => error, + }; + assert!(error.to_string().contains(STYLE_OBJECT), "{error}"); +} diff --git a/libs/extractor/src/styled_source_tests/css_prop_execution.rs b/libs/extractor/src/styled_source_tests/css_prop_execution.rs index a4e51bc3f..244115035 100644 --- a/libs/extractor/src/styled_source_tests/css_prop_execution.rs +++ b/libs/extractor/src/styled_source_tests/css_prop_execution.rs @@ -25,27 +25,33 @@ pub(super) fn css_props(filename: &str, css: &str, extra: &str) -> String { } pub(super) fn execute(filename: &str, source: &str, observation: &str) -> Value { + execute_in_mode(filename, source, observation, true) +} + +fn execute_in_mode(filename: &str, source: &str, observation: &str, single_css: bool) -> Value { let _state = crate::test_state::TestStateGuard::new(); reset_class_map(); reset_file_map(); + let option = ExtractOption { + single_css, + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + let bucket = crate::css_bucket(filename, &option); let output = extract( filename, &format!("import {{styled,css}} from '@devup-ui/react';import {{jsx}} from '@emotion/react';{source}"), - ExtractOption { - single_css: true, - import_aliases: std::collections::HashMap::from([( - "@emotion/react".to_string(), - crate::ImportAlias::NamedToNamed, - )]), - ..ExtractOption::default() - }, + option, ) .unwrap_or_else(|error| panic!("{filename}\n{source}\n{error}")); let mut rules = std::collections::BTreeMap::new(); for style in &output.styles { let rule = match style { crate::ExtractStyleValue::Static(style) => { - let StyleProperty::ClassName(name) = style.extract(None) else { + let StyleProperty::ClassName(name) = style.extract(bucket.as_deref()) else { panic!("static atom must have a class") }; Some(( @@ -67,7 +73,7 @@ pub(super) fn execute(filename: &str, source: &str, observation: &str) -> Value class_name, variable_name, .. - } = style.extract(None) + } = style.extract(bucket.as_deref()) else { panic!("dynamic atom must have a variable") }; @@ -127,9 +133,11 @@ pub(super) fn execute(filename: &str, source: &str, observation: &str) -> Value } } } - program - .body - .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + program.body.retain(|statement| { + !matches!(statement, Statement::ImportDeclaration(_)) + && !matches!(statement, Statement::ExportNamedDeclaration(export) + if export.specifiers.is_empty()) + }); let code = Codegen::new().build(&program).code; let rules = serde_json::to_string(&rules) .unwrap_or_else(|error| panic!("atom map must serialize: {error}")); @@ -154,3 +162,59 @@ function slots(element){{return Object.keys(element.props).filter(key=>key.start .to_std_string_escaped(); serde_json::from_str(&json).unwrap_or_else(|error| panic!("{error}\n{json}\n{code}")) } + +#[rstest] +#[case::emotion("emotionCss", false, "1px")] +#[case::native("css", false, "4px")] +#[case::styled("styled.div", true, "4px")] +#[serial] +fn conditional_rule_arrays_keep_one_getter_read_and_consistent_branches( + #[case] factory: &str, + #[case] styled: bool, + #[case] margin: &str, + #[values(false, true)] single_css: bool, +) { + let source = format!( + "import {{css as emotionCss}} from '@emotion/react';const trace=[];const state={{get on(){{trace.push('read');return trace.length===1;}}}};const f=()=>{factory}(state.on?[{{color:'red'}},{{margin:1}}]:[{{color:'blue'}}]);" + ); + let call = if styled { + "render({target:f(),props:{}})" + } else { + "({target:'div',props:{className:f()}})" + }; + let observation = format!( + "const first={call};const firstTrace=trace.slice();const second={call};JSON.stringify([firstTrace,trace,values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin')]);" + ); + let observed = execute_in_mode("test.ts", &source, &observation, single_css); + assert_eq!( + observed, + serde_json::json!([["read"], ["read", "read"], ["red"], [margin], ["blue"], []]), + ); +} + +#[path = "controller_evaluation.rs"] +mod controller_evaluation; + +#[path = "props_function_evaluation.rs"] +mod props_function_evaluation; + +#[path = "props_function_bindings.rs"] +mod props_function_bindings; + +#[path = "props_function_order.rs"] +mod props_function_order; + +#[path = "props_function_theme.rs"] +mod props_function_theme; + +#[path = "implicit_theme_scope.rs"] +mod implicit_theme_scope; + +#[path = "local_member_guard.rs"] +mod local_member_guard; + +#[path = "integration_semantics.rs"] +mod integration_semantics; + +#[path = "integration_execution.rs"] +mod integration_execution; diff --git a/libs/extractor/src/styled_source_tests/implicit_theme_scope.rs b/libs/extractor/src/styled_source_tests/implicit_theme_scope.rs new file mode 100644 index 000000000..73a288790 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/implicit_theme_scope.rs @@ -0,0 +1,134 @@ +use super::execute_in_mode; +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::catch( + "(...all)=>({color:(()=>{try{throw 0;}catch(all){}return all[0].theme.colors.brand;})()})" +)] +#[case::for_of( + "(...all)=>({color:(()=>{for(const all of []){}return all[0]['theme'].colors.brand;})()})" +)] +#[case::for_let( + "(...all)=>({color:(()=>{for(let all=0;all<0;all++){}return all[0].theme.colors.brand;})()})" +)] +#[case::for_in( + "(...all)=>({color:(()=>{for(const all in {}){}return all[0].theme.colors.brand;})()})" +)] +#[case::switch( + "(...all)=>({color:(()=>{switch(0){case 0:const all=[];break;}return all[0].theme.colors.brand;})()})" +)] +#[case::block_let("(...all)=>({color:(()=>{{let all=[];}return all[0].theme.colors.brand;})()})")] +#[case::block_const( + "(...all)=>({color:(()=>{{const all=[];}return all[0].theme.colors.brand;})()})" +)] +#[case::switch_discriminant( + "(...all)=>({color:(()=>{switch(all[0].theme.colors.brand){case 'red':const all=[];}return 'black';})()})" +)] +#[serial] +fn outer_props_theme_roots_return_after_nested_lexical_scopes( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given: every local shadow ends before the actual-props theme read. + reset_class_map(); + reset_file_map(); + let source = + format!("import {{styled}} from '@devup-ui/react';\nconst Card=styled.div({rules});"); + // When: extraction determines the owner of the rest binding after each scope. + let result = extract( + "implicit-scope-boundary.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then: a completed nested scope cannot suppress the existing theme error. + let error = match result { + Ok(output) => panic!("outer props theme read must remain refused: {output:?}"), + Err(error) => error.to_string(), + }; + assert!( + error.starts_with("implicit-scope-boundary.ts:2:"), + "{error}" + ); + assert!( + error.contains("a theme read must be a path of names or literal keys"), + "{error}" + ); +} + +#[rstest] +#[case::block_var( + "(...all)=>({color:(()=>{{var all=[{theme:{colors:{brand:'orange'}}}];}return all[0].theme.colors.brand;})()})" +)] +#[case::if_var( + "(...all)=>({color:(()=>{if(true){var all=[{theme:{colors:{brand:'orange'}}}];}return all[0].theme.colors.brand;})()})" +)] +#[case::catch_local( + "(...all)=>({color:(()=>{try{throw [{theme:{colors:{brand:'orange'}}}];}catch(all){return all[0].theme.colors.brand;}})()})" +)] +#[case::for_local( + "(...all)=>({color:(()=>{for(const all of [[{theme:{colors:{brand:'orange'}}}]])return all[0].theme.colors.brand;return 'black';})()})" +)] +#[serial] +fn local_theme_values_keep_their_binding_owner_across_var_catch_and_loop_scopes( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given: local data is orange while the actual props are red. + let source = format!("const Card=styled.div({rules});"); + // When: the emitted dynamic value executes in its authored scope. + let observed = execute_in_mode( + "test.ts", + &source, + "JSON.stringify(values(render({target:Card,props:{color:'red'}}),'color'));", + single_css, + ); + // Then: local objects are not mistaken for the callback's actual props. + assert_eq!(observed, serde_json::json!(["orange"])); +} + +#[rstest] +#[case::root_rest_var("(...all)=>{var all;return {color:all[0].theme.colors.brand};}")] +#[case::root_rest_block_var("(...all)=>{{var all;}return {color:all[0].theme.colors.brand};}")] +#[case::root_normal_rest_var("function(...all){var all;return {color:all[0].theme.colors.brand};}")] +#[case::root_normal_rest_block_var( + "function(...all){{var all;}return {color:all[0].theme.colors.brand};}" +)] +#[serial] +fn callback_statements_keep_the_existing_admission_error( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given: statements before the return are outside the whole-rule callback contract. + reset_class_map(); + reset_file_map(); + let source = + format!("import {{styled}} from '@devup-ui/react';\nconst Card=styled.div({rules});"); + // When: admission runs before theme-root analysis. + let result = extract( + "implicit-scope-boundary.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then: the established located explicit-object refusal still takes precedence. + let error = match result { + Ok(output) => panic!("unsupported callback statements must remain refused: {output:?}"), + Err(error) => error.to_string(), + }; + assert!( + error.starts_with("implicit-scope-boundary.ts:2:"), + "{error}" + ); + assert!( + error.contains("a style function of the props must give one rule object at once"), + "{error}" + ); +} diff --git a/libs/extractor/src/styled_source_tests/integration_execution.rs b/libs/extractor/src/styled_source_tests/integration_execution.rs new file mode 100644 index 000000000..c50afda89 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/integration_execution.rs @@ -0,0 +1,73 @@ +use super::*; + +#[rstest] +#[case::renamed("import {css as paint} from '@devup-ui/react';", "paint", false)] +#[case::namespace("import * as ui from '@devup-ui/react';", "ui.styled.div", true)] +#[case::alias("const factory=styled;", "factory.div", true)] +#[serial] +fn semantic_factories_keep_authored_schedule_when_arrays_capture_getters( + #[case] setup: &str, + #[case] factory: &str, + #[case] component: bool, + #[values(false, true)] single_css: bool, +) { + // Given + let source = format!( + "{setup}const trace=[];let n=0;const state={{get before(){{trace.push('before');return 'before';}},get on(){{trace.push('on');return ++n===1;}},get middle(){{trace.push('middle');return 'middle';}},get after(){{trace.push('after');return 'after';}}}};const f=input=>{factory}(input.before,input.on?[{{color:'red'}},{{margin:1}}]:[{{color:'blue'}}],input.middle,input.on?[]:[],input.after);const defined=trace.slice();" + ); + let call = if component { + "render({target:f(state),props:{}})" + } else { + "({props:{className:f(state)}})" + }; + let observation = format!( + "const first={call};const firstTrace=trace.slice();const second={call};JSON.stringify([defined,firstTrace,trace,values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin')]);" + ); + // When + let result = execute_in_mode("integration.ts", &source, &observation, single_css); + // Then + assert_eq!( + result, + serde_json::json!([ + [], + ["before", "on", "middle", "on", "after"], + [ + "before", "on", "middle", "on", "after", "before", "on", "middle", "on", "after" + ], + ["red"], + ["4px"], + ["blue"], + [] + ]) + ); +} + +#[rstest] +#[case::namespace("import * as ui from '@devup-ui/react';", "ui.styled")] +#[case::alias("const factory=styled;", "factory")] +#[serial] +fn callbacks_keep_own_arguments_and_defaults_when_factories_use_semantic_aliases( + #[case] setup: &str, + #[case] factory: &str, + #[values(false, true)] single_css: bool, +) { + // Given + let source = format!( + "{setup}const trace=[];const Card={factory}('section')(function(p,other=(trace.push('default'),'red')){{return {{color:(trace.push(arguments.length),other),margin:arguments[0].m}};}});const defined=trace.slice();" + ); + let observation = "const first=render({target:Card,props:{ m: 2 }});const second=render({target:Card,props:{ m: 3 }});JSON.stringify([defined,trace,first.target,values(first,'color'),values(first,'margin'),values(second,'margin')]);"; + // When + let result = execute_in_mode("integration.ts", &source, observation, single_css); + // Then + assert_eq!( + result, + serde_json::json!([ + [], + ["default", 1, "default", 1], + "section", + ["red"], + ["2px"], + ["3px"] + ]) + ); +} diff --git a/libs/extractor/src/styled_source_tests/integration_semantics.rs b/libs/extractor/src/styled_source_tests/integration_semantics.rs new file mode 100644 index 000000000..ff79e67c0 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/integration_semantics.rs @@ -0,0 +1,271 @@ +use super::*; +use crate::ExtractStyleValue; + +fn rejection(result: Result>) -> String { + match result { + Ok(output) => panic!("expected located refusal: {output:?}"), + Err(error) => error.to_string(), + } +} + +fn options(single_css: bool) -> ExtractOption { + ExtractOption { + single_css, + import_aliases: std::collections::HashMap::from([ + ( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + ), + ( + "@emotion/styled".to_string(), + crate::ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + ..ExtractOption::default() + } +} + +#[rstest] +#[case::root_changed("let rules={color:'blue'};rules.color='green';", "")] +#[case::root_unknown("const rules=getRules();", "")] +#[case::sibling("function other(){const rules={color:'blue'};observe(rules);}", "")] +#[case::shadowed_api( + "", + "function other(css){const rules={color:'blue'};return css(rules);}" +)] +#[serial] +fn local_demand_stays_separate_when_module_names_overlap( + #[case] before: &str, + #[case] after: &str, + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css as make}} from '@devup-ui/react';{before}export function f(){{const rules={{color:'red'}};return make(rules);}}{after}" + ); + // When + let output = extract("intersection.tsx", &source, options(single_css)) + .unwrap_or_else(|error| panic!("{error}")); + // Then + let colors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property() == "color" => Some(style.value()), + _ => None, + }) + .collect(); + assert_eq!(colors, ["red"]); +} + +#[rstest] +#[case::template("const holder={rules:{color:'red'}};", "make`${holder.rules}`")] +#[case::selected_template( + "const holder={rules:{color:'red'},other:{color:'blue'}};", + "make`${true?holder.rules:holder.other}`" +)] +#[case::class_names( + "const holder={rules:{color:'red'}};", + "{({css:paint})=>
}" +)] +#[serial] +fn erased_local_reads_keep_extra_use_errors_when_templates_or_class_names_consume_them( + #[case] declaration: &str, + #[case] expression: &str, + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css as make}} from '@devup-ui/react';import {{ClassNames}} from '@emotion/react';\nexport function f(){{{declaration}observe(holder);return {expression};}}" + ); + // When + let result = extract("intersection.tsx", &source, options(single_css)); + // Then + let error = rejection(result); + assert!(error.starts_with("intersection.tsx:2:27:"), "{error}"); + assert!( + error.contains(&crate::utils::local_style_error("holder")), + "{error}" + ); +} + +#[rstest] +#[case::leaf("const Card=styled.div(p=>({ color: alias }));")] +#[case::default("const Card=styled.div((p=alias)=>({color:p.color}));")] +#[case::controller("const Card=styled.div(alias?[{color:'red'}]:[{color:'blue'}]);")] +#[case::attrs("const Card=styled.div.attrs({ title: alias })({color:'red'});")] +#[serial] +fn captured_reads_are_located_when_the_original_alias_is_removed( + #[case] body: &str, + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{css,styled}} from '@devup-ui/react';\nconst alias=css;{body}"); + // When + let result = extract("intersection.tsx", &source, options(single_css)); + // Then + let error = rejection(result); + assert!(error.contains("intersection.tsx:2:"), "{error}"); + assert!(error.contains("`alias` is read at runtime"), "{error}"); +} + +#[rstest] +#[case::shadowed_leaf("const Card=styled.div(alias=>({color:alias.color}));")] +#[case::shadowed_attrs( + "function f(alias){return styled.div.attrs({ title: alias })({color:'red'});}" +)] +#[case::nested_attrs("const Card=styled.div.attrs({title:alias({color:'red'})})({margin:1});")] +#[case::ordinary_attrs( + "function f(styled){return styled.div({color:'blue'}).attrs({id:'x'});}const Card=styled.div({color:'red'});" +)] +#[case::generic_type( + "import * as ui from '@devup-ui/react';function f(){const rules={color:'red'};const Card=ui.styled.div(function rules(p:rules){return {color:'blue'};});return [alias(rules),Card];}" +)] +#[case::parameter("function f(styled){return styled.div(p=>({color:p.color})).attrs({id:'x'});}")] +#[case::block("{const styled=ordinary;styled.div(p=>({color:p.color})).attrs({id:'x'});}")] +#[case::caught( + "try{throw ordinary;}catch(styled){styled.div(p=>({color:p.color})).attrs({id:'x'});}" +)] +#[case::iteration( + "for(const styled of factories){styled.div(p=>({color:p.color})).attrs({id:'x'});}" +)] +#[serial] +fn semantic_controls_compile_when_callback_or_factory_bindings_are_distinct( + #[case] body: &str, + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!("import {{css,styled}} from '@devup-ui/react';const alias=css;{body}"); + // When + let result = extract("intersection.tsx", &source, options(single_css)); + // Then + assert!(result.is_ok(), "{result:?}"); +} + +#[rstest] +#[case::opaque("cx(getClass())")] +#[case::member("cx(source.getClass())")] +#[case::mixed("cx([1,on?getClass():paint({color:'red'})])")] +#[serial] +fn class_names_refuses_opaque_calls_when_callback_apis_are_renamed( + #[case] expression: &str, + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{ClassNames}} from '@emotion/react';export const App=()=> {{({{css:paint,cx}})=>
}};" + ); + // When + let result = extract("intersection.tsx", &source, options(single_css)); + // Then + let error = rejection(result); + assert!(error.contains("intersection.tsx:1:"), "{error}"); + assert!( + error.contains("cannot use") || error.contains("Cannot compose"), + "{error}" + ); +} + +#[rstest] +#[serial] +fn local_records_keep_fallback_units_when_native_and_emotion_consumers_mix( + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {css as native} from '@devup-ui/react';import {css as paint} from '@emotion/react';export function f(){const rules={padding:[2,4],opacity:[0,0.5],margin:0,vars:{'--gap':7}};return [paint(rules),native({margin:2})];}"; + // When + let output = extract("intersection.tsx", source, options(single_css)) + .unwrap_or_else(|error| panic!("{error}")); + // Then + let declarations: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some((style.property(), style.value())), + _ => None, + }) + .collect(); + for expected in [ + ("padding", "2px;padding:4px"), + ("opacity", "0;opacity:.5"), + ("margin", "0"), + ("margin", "8px"), + ("--gap", "7"), + ] { + assert!( + declarations.contains(&expected), + "{expected:?}: {declarations:?}" + ); + } +} + +#[rstest] +#[case::safe(false)] +#[case::observed(true)] +#[serial] +fn imported_metadata_is_readable_only_when_local_rules_are_style_only( + #[case] observed: bool, + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let allocator = Allocator::default(); + let source = "import {styled} from '@devup-ui/react';import {Card} from './base';export const Child=styled(Card)({ margin: 1 });"; + let program = parse(&allocator, "/src/app.tsx", source); + let option = options(single_css); + let base = format!( + "import {{css,styled}} from '@devup-ui/react';const rules={{color:'pink'}};const seed=css(rules);export const Card=styled.div({{color:'red'}});export function f(){{const rules={{color:'blue'}};{}return css(rules);}}", + if observed { "observe(rules);" } else { "" } + ); + let resolver = move |specifier: &str, _: &str| { + (specifier == "./base").then(|| crate::ResolvedModule { + path: "/src/base.tsx".to_string(), + code: base.clone(), + }) + }; + // When + let reading = crate::imported_styled::read( + &oxc_ast::builder::AstBuilder::new(&allocator), + &program, + "/src/app.tsx", + &option, + Some(&resolver), + crate::css_prop::CssProp::Off, + ); + // Then + let card = reading + .components + .get("Card") + .unwrap_or_else(|| panic!("missing imported component metadata")); + assert_eq!(card.definition.is_some(), !observed); +} + +#[rstest] +#[serial] +fn unfinished_creator_is_refused_when_only_its_first_call_is_written( + #[values(false, true)] single_css: bool, +) { + // Given + reset_class_map(); + reset_file_map(); + let source = "import {styled} from '@devup-ui/react';const creator=styled('section');"; + // When + let result = extract("intersection.tsx", source, options(single_css)); + // Then + let error = rejection(result); + assert!(error.starts_with("intersection.tsx:1:"), "{error}"); + assert!(error.contains("`styled` is read at runtime"), "{error}"); +} diff --git a/libs/extractor/src/styled_source_tests/local_member_accounting.rs b/libs/extractor/src/styled_source_tests/local_member_accounting.rs new file mode 100644 index 000000000..c8c640b07 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/local_member_accounting.rs @@ -0,0 +1,193 @@ +use super::*; + +#[rstest] +#[case::discarded_alternate("css(true ? holder.rules : holder.other)")] +#[case::discarded_consequent("css(false ? holder.other : holder.rules)")] +#[case::and_right("css(holder.on && holder.rules)")] +#[case::and_left("css(holder.off && holder.other, holder.rules)")] +#[case::or_boolean_left("css(holder.on || holder.other, holder.rules)")] +#[case::or_left("css(holder.rules || holder.other)")] +#[case::or_right("css(holder.off || holder.rules)")] +#[case::nullish_left("css(holder.rules ?? holder.other)")] +#[case::nullish_right("css(holder.none ?? holder.rules)")] +#[case::nested_choices("css(holder.on ? (holder.none ?? holder.rules) : holder.other)")] +#[case::discarded_nested("css(holder.rules, false ? holder.other : {color:'red'})")] +#[case::repeated_computed("css(holder[holder.key], holder[holder.key])")] +#[case::repeated_operands("css({color:holder.base+holder.suffix+holder.empty})")] +#[case::template_operands("css({color:`${holder.base}${holder.suffix}${holder.empty}`})")] +#[serial] +fn style_only_reads_compile_when_selection_erases_other_references( + #[case] expression: &str, + #[values(false, true)] single_css: bool, +) { + // Given: all semantic references belong to the style expression, including discarded sides. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css}} from '@devup-ui/react';\nexport function f(){{const holder={{on:true,off:false,none:null,key:'rules',base:'r',suffix:'ed',empty:'',rules:{{color:'red'}},other:{{color:'blue'}}}};return {expression};}}" + ); + // When: the shared inliner folds the expression before style extraction. + let output = extract( + "local-accounting.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{expression}: {error}")); + // Then: only the selected red declaration is emitted. + let colors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property() == "color" => Some(style.value()), + _ => None, + }) + .collect(); + assert_eq!(colors, vec!["red"], "{expression}"); +} + +#[rstest] +#[case::discarded_only("css(true ? {color:'red'} : holder.rules)")] +#[case::discarded_consequent("css(false ? holder.rules : {color:'red'})")] +#[case::or_retained_left("css(holder.rules || holder.other)")] +#[case::nullish_retained_left("css(holder.rules ?? holder.other)")] +#[case::nullish_right("css(holder.none ?? holder.rules)")] +#[case::and_discarded_right("css({color:holder.color}, false && holder.rules)")] +#[case::repeated_computed("css(holder[holder.key], holder[holder.key])")] +#[case::folded_math("css({width:Math.max(holder.size,holder.size)})")] +#[case::folded_template("css({color:`${holder.color}${holder.empty}`})")] +#[serial] +fn local_guard_keeps_its_location_when_erased_reads_have_an_extra_use( + #[case] expression: &str, + #[values(false, true)] single_css: bool, +) { + // Given: observation is a distinct semantic reference outside the style expression. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css}} from '@devup-ui/react';\nexport function f(){{const holder={{none:null,key:'rules',color:'red',empty:'',size:2,rules:{{color:'red'}},other:{{color:'blue'}}}};observe(holder);return {expression};}}" + ); + // When: successful folding or selection consumes the style references. + let result = extract( + "local-accounting.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + let error = match result { + Ok(output) => panic!("erased local references must retain the extra-use error: {output:?}"), + Err(error) => error.to_string(), + }; + // Then: the original binding location and refusal remain authoritative. + assert!(error.starts_with("local-accounting.ts:2:27: "), "{error}"); + assert!( + error.contains(&crate::utils::local_style_error("holder")), + "{error}" + ); +} + +#[rstest] +#[case::sibling("export function other(){const holder={rules:{color:'blue'}};observe(holder);}")] +#[case::shadow("export function other(holder){observe(holder);}")] +#[case::outer("const holder={rules:{color:'blue'}};observe(holder);")] +#[serial] +fn accounting_uses_binding_identity_when_names_are_shared( + #[case] adjacent: &str, + #[values(false, true)] single_css: bool, +) { + // Given: an unrelated binding shares the spelling of the style-only local. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css}} from '@devup-ui/react';{adjacent}\nexport function f(){{const holder={{key:'rules',rules:{{color:'red'}}}};return css(holder[holder.key]);}}" + ); + // When: references are resolved across sibling and nested scopes. + let output = extract( + "local-accounting.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + // Then: the unrelated observation neither refuses nor changes the style-only binding. + assert!(output.styles.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.property() == "color" && style.value() == "red" + ))); +} + +#[rstest] +#[case::calls("return [nativeCss(holder.rules),css(holder.rules)];")] +#[case::elements("return <>
;")] +#[case::class_names( + "return <>{({css})=>
};" +)] +#[serial] +fn adjacent_consumers_keep_units_when_they_share_a_local_member( + #[case] body: &str, + #[values(false, true)] single_css: bool, +) { + // Given: native and library consumers read the same local object with different units. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{css as nativeCss,Box}} from '@devup-ui/react';import {{css,ClassNames}} from '@emotion/react';\nexport function f(){{const holder={{rules:{{color:'red',margin:2}}}};{body}}}" + ); + // When: folded members pass through their original numeric-context handling. + let output = extract( + "local-accounting.tsx", + &source, + ExtractOption { + single_css, + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + crate::ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + let mut margins: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property() == "margin" => Some(style.value()), + _ => None, + }) + .collect(); + margins.sort_unstable(); + // Then: library pixels and native scaled pixels both survive. + assert_eq!(margins, vec!["2px", "8px"]); +} + +#[rstest] +#[case::computed("css(holder[runtime])")] +#[case::selected_runtime("css(true ? holder[runtime] : {color:'red'})")] +#[case::failed_math("css({width:Math.max('unknown',holder)})")] +fn runtime_references_stay_uncounted_when_folding_cannot_erase_them(#[case] expression: &str) { + // Given: a local object has runtime uses but no successfully folded style read. + let allocator = oxc_allocator::Allocator::default(); + let source = format!( + "import {{css}} from '@devup-ui/react';function f(runtime){{const holder={{rules:{{color:'red'}}}};observe(holder);return {expression};}}" + ); + let mut program = oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::ts()) + .parse() + .program; + // When: only the shared constant-inlining pass runs, without downstream evaluation. + let inlined = crate::imported_constants::inline_constants( + &oxc_ast::builder::AstBuilder::new(&allocator), + &mut program, + "local-accounting.ts", + &ExtractOption::default(), + None, + crate::css_prop::CssProp::Off, + &Default::default(), + ); + // Then: probes and still-runtime selected subtrees do not activate the extra-use policy. + assert_eq!(inlined.errors, vec![]); +} diff --git a/libs/extractor/src/styled_source_tests/local_member_guard.rs b/libs/extractor/src/styled_source_tests/local_member_guard.rs new file mode 100644 index 000000000..ebf922fda --- /dev/null +++ b/libs/extractor/src/styled_source_tests/local_member_guard.rs @@ -0,0 +1,96 @@ +use crate::{ExtractOption, ExtractStyleValue, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::member_mutation( + "const holder={rules:{color:'red'}};holder.rules.color='blue';return css(holder.rules);" +)] +#[case::computed_mutation( + "const holder={rules:{color:'red'}};holder['rules'].color='blue';return css(holder['rules']);" +)] +#[case::extra_read("const holder={rules:{color:'red'}};observe(holder);return css(holder.rules);")] +#[case::primitive_fold( + "const holder={color:'red'};holder.color='blue';return css({color:holder.color});" +)] +#[case::conditional_test( + concat!("const holder=", "{", "on:true", "}", ";holder.on=false;return css(holder.on?{color:'red'}:{color:'blue'});") +)] +#[case::logical_test( + concat!("const holder=", "{", "on:true", "}", ";holder.on=false;return css(holder.on&&[{color:'red'}]);") +)] +#[case::nested_array( + "const holder={rules:[{color:'red'}]};holder.rules[0].color='blue';return css(holder.rules[0]);" +)] +#[case::computed_two_reads( + "const holder={key:'rules',rules:{color:'red'}};observe(holder);return css(holder[holder.key]);" +)] +#[serial] +fn folded_local_members_keep_the_existing_extra_use_error( + #[case] body: &str, + #[values(false, true)] single_css: bool, +) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{css}} from '@devup-ui/react';\nexport function f(){{{body}}}"); + let result = extract( + "local-member.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + let error = match result { + Ok(output) => panic!("folding must not erase a non-style use: {output:?}"), + Err(error) => error.to_string(), + }; + assert!(error.starts_with("local-member.ts:2:"), "{error}"); + assert!( + error.contains("Cannot read `holder` as styles at build time: it is also read elsewhere"), + "{error}" + ); +} + +#[rstest] +#[case::member("const holder={rules:{color:'red'}};return css(holder.rules);")] +#[case::computed("const holder={key:'rules',rules:{color:'red'}};return css(holder[holder.key]);")] +#[case::repeated("const holder={rules:{color:'red'}};return css(holder.rules,holder.rules);")] +#[case::conditional( + "const holder={on:true,rules:{color:'red'},other:{color:'blue'}};return css(holder.on?holder.rules:holder.other);" +)] +#[case::logical("const holder={on:false,rules:{color:'red'}};return css(holder.on||holder.rules);")] +#[case::folded_string( + "const holder={base:'r',suffix:'ed'};return css({color:holder.base+holder.suffix});" +)] +#[serial] +fn style_only_member_and_condition_reads_keep_static_values( + #[case] body: &str, + #[values(false, true)] single_css: bool, +) { + reset_class_map(); + reset_file_map(); + let source = format!("import {{css}} from '@devup-ui/react';\nexport function f(){{{body}}}"); + let output = extract( + "local-member-safe.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("safe style-only members must compile: {error}")); + let colors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.property() == "color" => Some(style.value()), + _ => None, + }) + .collect(); + assert_eq!(colors, vec!["red"]); +} + +#[path = "local_member_accounting.rs"] +mod local_member_accounting; diff --git a/libs/extractor/src/styled_source_tests/props_function_bindings.rs b/libs/extractor/src/styled_source_tests/props_function_bindings.rs new file mode 100644 index 000000000..5bdfa1acf --- /dev/null +++ b/libs/extractor/src/styled_source_tests/props_function_bindings.rs @@ -0,0 +1,179 @@ +use super::execute_in_mode; +use rstest::rstest; +use serial_test::serial; + +#[path = "props_function_self_reference.rs"] +mod self_reference; + +#[path = "props_function_generic_self_reference.rs"] +mod generic_self_reference; + +#[rstest] +#[serial] +fn parameter_defaults_and_rest_run_once_when_rules_have_multiple_leaves( + #[values(false, true)] single_css: bool, +) { + // Given: defaults do observable work and the callback receives only one actual argument. + let source = "const trace=[];function fallback(){trace.push('fallback');return {c:'red',m:2};}const Card=styled.div(({data:{c,m}=fallback()},unused=(trace.push('extra'),'unused'),...tail)=>({color:c,margin:m,opacity:tail.length}));const defined=trace.slice();"; + let observation = "const first=render({target:Card,props:{}});const firstTrace=trace.slice();const second=render({target:Card,props:{data:{c:'blue',m:3}}});JSON.stringify([defined,firstTrace,trace,values(first,'color'),values(first,'margin'),values(first,'opacity'),values(second,'color'),values(second,'margin')]);"; + // When: two renders take different branches of the destructuring default. + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then: binding work happens once per render, not once per surviving declaration. + assert_eq!( + observed, + serde_json::json!([ + [], + ["fallback", "extra"], + ["fallback", "extra", "extra"], + ["red"], + ["2px"], + [0], + ["blue"], + ["3px"] + ]) + ); +} + +#[rstest] +#[case::empty("{}", serde_json::json!([]))] +#[case::static_rules("{color:'red'}", serde_json::json!(["red"]))] +#[serial] +fn defaults_keep_their_props_when_the_value_vector_is_empty( + #[case] rules: &str, + #[case] colors: serde_json::Value, + #[values(false, true)] single_css: bool, +) { + // Given: the only props read is in a default initializer, including through authored JSX. + let source = format!( + "const trace=[];const Card=styled.div((p,unused=(trace.push(p.token),0))=>({rules}));const defined=trace.slice();const App=token=>jsx(Card,{{token}});" + ); + let observation = "const first=render(App('one'));const firstTrace=trace.slice();const second=render(App('two'));JSON.stringify([defined,firstTrace,trace,values(first,'color'),values(second,'color'),'token' in first.props]);"; + // When: static-only and empty callbacks render twice. + let observed = execute_in_mode("test.ts", &source, observation, single_css); + // Then: no definition-time work or erased initializer; the style-only prop stays withheld. + assert_eq!( + observed, + serde_json::json!([[], ["one"], ["one", "two"], colors, colors, false]) + ); +} + +#[rstest] +#[serial] +fn unused_nested_binding_still_reads_when_the_rule_object_is_empty( + #[values(false, true)] single_css: bool, +) { + // Given: no dynamic leaf survives, but destructuring itself has an observable getter. + let source = "const trace=[];const data={get c(){trace.push('c');return 'unused';}};const Card=styled.div(({data:{c}})=>({}));const defined=trace.slice();"; + // When: the generated component renders twice. + let observed = execute_in_mode( + "test.ts", + source, + "render({target:Card,props:{data}});render({target:Card,props:{data}});JSON.stringify([defined,trace]);", + single_css, + ); + // Then: empty vectors still invoke the original parameter binding. + assert_eq!(observed, serde_json::json!([[], ["c", "c"]])); +} + +#[rstest] +#[serial] +fn rest_only_props_survive_when_authored_elements_call_the_component( + #[values(false, true)] single_css: bool, +) { + // Given: the first actual props object is reached through a rest formal. + let source = "const Card=styled.div((...all)=>({color:all[0].tone,opacity:all.length}));const App=tone=>jsx(Card,{tone});"; + // When: the real JSX-call extraction and generated renderer run together. + let observed = execute_in_mode( + "test.ts", + source, + "const first=render(App('red'));const second=render(App('blue'));JSON.stringify([values(first,'color'),values(second,'color'),values(first,'opacity')]);", + single_css, + ); + // Then: there is exactly one actual argument and no pruned input to the rest binding. + assert_eq!(observed, serde_json::json!([["red"], ["blue"], [1]])); +} + +#[rstest] +#[serial] +fn arrows_keep_lexical_arguments_and_this_when_the_factory_has_both( + #[values(false, true)] single_css: bool, +) { + // Given: lexical factory state deliberately differs from render props. + let source = "const receiver={color:'orange'};function make(factoryReceiver){return styled.div(p=>({color:arguments[0].color,opacity:this===factoryReceiver?0.5:0,margin:p.margin}));}const Card=make.call(receiver,receiver);"; + // When: two renders invoke the original arrow inside compiler-generated arrow wrappers. + let observed = execute_in_mode( + "test.ts", + source, + "const first=render({target:Card,props:{color:'red',margin:2}});const second=render({target:Card,props:{color:'blue',margin:3}});JSON.stringify([values(first,'color'),values(second,'color'),values(first,'opacity'),values(first,'margin'),values(second,'margin')]);", + single_css, + ); + // Then: lexical scope is retained while the actual props binding changes per render. + assert_eq!( + observed, + serde_json::json!([["orange"], ["orange"], [0.5], ["2px"], ["3px"]]) + ); +} + +#[rstest] +#[serial] +fn normal_callback_direct_scope_survives_when_named_without_self_references( + #[values(false, true)] single_css: bool, +) { + // Given: a named callback observes strict this and its own actual arguments, not its identity. + let source = "const Card=styled.div(function rules(p){'use strict';return {color:this===undefined&&arguments.length===1&&arguments[0]===p?arguments[0].color:'orange'};});"; + // When: the generated renderer invokes the original function. + let observed = execute_in_mode( + "test.ts", + source, + "const element=render({target:Card,props:{color:'red'}});JSON.stringify(values(element,'color'));", + single_css, + ); + // Then: no added actual argument or receiver changes the direct-call contract. + assert_eq!(observed, serde_json::json!(["red"])); +} + +#[rstest] +#[case::normal_leaf( + "p=>({color:function(q){'use strict';return this===undefined&&arguments.length===1&&q===p?p.color:'orange';},margin:q=>p.margin})" +)] +#[case::arrow_leaf( + "function(p){'use strict';return {color:()=>arguments[0].color,margin:()=>p.margin};}" +)] +#[serial] +fn function_leaves_retain_outer_bindings_when_invoked_without_an_array_receiver( + #[case] callback: &str, + #[values(false, true)] single_css: bool, +) { + // Given: leaves close over outer callback bindings, rather than a recreated parameter list. + let source = format!("const Card=styled.div({callback});"); + // When: both leaf kinds execute through real generated style expressions in two renders. + let observed = execute_in_mode( + "test.ts", + &source, + "const first=render({target:Card,props:{color:'red',margin:2}});const second=render({target:Card,props:{color:'blue',margin:3}});JSON.stringify([values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin')]);", + single_css, + ); + // Then: closure state is current and ordinary property callbacks keep bare numeric values. + assert_eq!(observed, serde_json::json!([["red"], [2], ["blue"], [3]])); +} + +#[rstest] +#[case::no_formals("function(){return {color:arguments[0].tone};}")] +#[case::unused_formal("function(p){return {color:arguments[0].tone};}")] +#[serial] +fn own_arguments_keep_original_props_reads_when_authored_jsx_would_otherwise_prune_them( + #[case] callback: &str, + #[values(false, true)] single_css: bool, +) { + // Given: a non-DOM input is read only through the normal callback's own arguments object. + let source = format!("const Card=styled.div({callback});const App=tone=>jsx(Card,{{tone}});"); + // When: authored JSX and the generated renderer execute together for two props values. + let observed = execute_in_mode( + "test.ts", + &source, + "const first=render(App('red'));const second=render(App('blue'));JSON.stringify([values(first,'color'),values(second,'color')]);", + single_css, + ); + // Then: original arguments reads retain the actual props rather than a pruned empty object. + assert_eq!(observed, serde_json::json!([["red"], ["blue"]])); +} diff --git a/libs/extractor/src/styled_source_tests/props_function_evaluation.rs b/libs/extractor/src/styled_source_tests/props_function_evaluation.rs new file mode 100644 index 000000000..7a4ff7a44 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/props_function_evaluation.rs @@ -0,0 +1,216 @@ +use super::execute_in_mode; +use crate::{ExtractOption, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::arguments("arguments[0].color")] +#[case::receiver("this===factoryReceiver?'orange':p.color")] +#[serial] +fn whole_rule_normal_functions_keep_invocation_scope( + #[case] value: &str, + #[values(false, true)] single_css: bool, +) { + // Given: a normal factory's receiver and argument differ from callback inputs. + let source = format!( + "const receiver={{color:'orange'}};function make(factoryReceiver){{return styled.div(function(p){{return {{color:{value}}};}});}}const Card=make.call(receiver,receiver);" + ); + let observation = "const first=render({target:Card,props:{color:'red'}});const second=render({target:Card,props:{color:'blue'}});JSON.stringify([values(first,'color'),values(second,'color')]);"; + // When: the extracted whole-rule callback executes in two actual renders. + let observed = execute_in_mode("test.ts", &source, observation, single_css); + // Then: arguments belong to the callback; direct invocation never inherits the factory receiver. + assert_eq!(observed, serde_json::json!([["red"], ["blue"]])); +} + +#[rstest] +#[serial] +fn nested_props_bindings_run_once_per_render_across_dynamic_leaves( + #[values(false, true)] single_css: bool, +) { + // Given: both nested bindings have changing getters shared by two dynamic leaves. + let source = "const trace=[];let cReads=0;let mReads=0;const data={get c(){trace.push('c');return ++cReads===1?'red':'blue';},get m(){trace.push('m');return `${++mReads*4}px`;}};const Card=styled.div(({data:{c,m}})=>({color:c,margin:m}));const defined=trace.slice();"; + let observation = "const first=render({target:Card,props:{data}});const firstTrace=trace.slice();const second=render({target:Card,props:{data}});JSON.stringify([defined,firstTrace,trace,values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin')]);"; + // When: real extracted code binds the same getter-backed props on each render. + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then: each render binds c,m once, and both leaves use that same binding pass. + assert_eq!( + observed, + serde_json::json!([ + [], + ["c", "m"], + ["c", "m", "c", "m"], + ["red"], + ["4px"], + ["blue"], + ["8px"] + ]) + ); +} + +#[rstest] +#[case::property_props("{color:(p,unused)=>p.theme.colors[key]}")] +#[case::property_theme_alias("{color:({theme:palette},unused)=>palette.colors[key]}")] +#[case::whole_rule_props("(p,unused)=>({color:p.theme.colors[key]})")] +#[case::whole_rule_theme_alias("({theme:palette},unused)=>({color:palette.colors[key]})")] +#[serial] +fn additional_params_keep_dynamic_theme_paths_as_located_errors( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given: a runtime key and an unused second formal for either supported theme root. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled,css}} from '@devup-ui/react';import {{jsx}} from '@emotion/react';\nconst make=key=>styled.div({rules});" + ); + let option = ExtractOption { + single_css, + import_aliases: std::collections::HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }; + // When: extraction sees the unprovable theme path, without executing runtime CSS. + let result = extract("props-theme.ts", &source, option); + // Then: extra formals cannot turn the existing located theme refusal into a CSS variable. + let error = match result { + Ok(output) => panic!("dynamic theme paths must remain refused: {output:?}"), + Err(error) => error.to_string(), + }; + let Some((column, _)) = error + .strip_prefix("props-theme.ts:2:") + .and_then(|located| located.split_once(':')) + else { + panic!("theme refusal must locate the authored callback: {error}"); + }; + assert!( + matches!(column.parse::(), Ok(column) if column > 0), + "{error}" + ); + assert!(error.contains("cannot use"), "{error}"); + assert!(error.contains("at build time:"), "{error}"); + assert!( + error.contains("a theme read must be a path of names or literal keys"), + "{error}" + ); +} + +#[rstest] +#[case::normal("function(p){return{color:arguments[0].theme.colors[key]};}")] +#[case::normal_without_formals("function(){return{color:arguments[0]['theme'].colors[key]};}")] +#[case::rest("(...all)=>({color:all[0].theme.colors[key]})")] +#[case::property_normal("{color:function(p){return arguments[0].theme.colors[key];}}")] +#[case::property_rest("{color:(...all)=>all[0]['theme'].colors[key]}")] +#[serial] +fn implicit_props_roots_keep_unprovable_theme_reads_as_located_errors( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given: these callback forms receive the actual props as their first argument. + reset_class_map(); + reset_file_map(); + let source = + format!("import {{styled}} from '@devup-ui/react';\nconst make=key=>styled.div({rules});"); + // When: an unprovable theme path is reached through own arguments or a rest formal. + let result = extract( + "implicit-theme.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then: alternate access to the actual props cannot emit runtime theme access. + let error = match result { + Ok(output) => panic!("implicit props theme reads must remain refused: {output:?}"), + Err(error) => error.to_string(), + }; + assert!(error.starts_with("implicit-theme.ts:2:"), "{error}"); + assert!( + error.contains("a theme read must be a path of names or literal keys"), + "{error}" + ); +} + +#[rstest] +#[case::arguments_string_zero("function(){return{color:arguments['0']['theme'].colors.brand};}")] +#[case::rest_string_zero("(...all)=>({color:all['0'].theme.colors.brand})")] +#[case::lexical_arguments("function(){return{color:(()=>arguments[0].theme.colors.brand)()};}")] +#[case::captured_rest("(...all)=>({color:(function(){return all[0].theme.colors.brand})()})")] +#[case::property_no_formals("{color:function(){return arguments['0'].theme.colors.brand;}}")] +#[case::property_rest_normal("{color:function(...all){return all['0']['theme'].colors.brand;}}")] +#[case::mixed_mapped( + "function(p){return{background:p.theme.colors.brand,color:arguments[0].theme.colors.brand};}" +)] +#[serial] +fn implicit_theme_roots_remain_errors_inside_inheriting_scopes( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given: implicit roots are not new statically mapped theme roots, even with literal paths. + reset_class_map(); + reset_file_map(); + let source = + format!("import {{styled}} from '@devup-ui/react';\nconst Card=styled.div({rules});"); + // When: extraction visits original callback scope before rewriting its leaves. + let result = extract( + "implicit-scope.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then: the same theme refusal locates the authored callback. + let error = match result { + Ok(output) => panic!("implicit theme roots must not become runtime reads: {output:?}"), + Err(error) => error.to_string(), + }; + assert!(error.starts_with("implicit-scope.ts:2:"), "{error}"); + assert!( + error.contains("a theme read must be a path of names or literal keys"), + "{error}" + ); +} + +#[rstest] +#[case::normal("function(){return{color:arguments[0].color};}", "red")] +#[case::rest("(...all)=>({color:all['0'].color})", "red")] +#[case::property_normal("{color:function(){return arguments['0'].color;}}", "red")] +#[case::property_rest("{color:(...all)=>all[0].color}", "red")] +#[case::factory_arguments("()=>({color:arguments[0].theme.colors.brand})", "orange")] +#[case::property_factory_arguments("{color:()=>arguments[0].theme.colors.brand}", "orange")] +#[case::nested_normal( + "function(){return{color:(function(){return arguments[0].theme.colors.brand})({theme:{colors:{brand:'orange'}}})};}", + "orange" +)] +#[case::shadowed_rest( + "(...all)=>({color:((all)=>all[0].theme.colors.brand)([{theme:{colors:{brand:'orange'}}}])})", + "orange" +)] +#[case::later_rest( + "(p,...all)=>({color:all.length===0?p.color:all[0].theme.colors.brand})", + "red" +)] +#[case::mapped_and_dynamic( + "function(p){return{background:p.theme.colors.brand,color:arguments[0].color};}", + "red" +)] +#[serial] +fn implicit_theme_guard_keeps_non_theme_and_unrelated_argument_scopes( + #[case] rules: &str, + #[case] expected: &str, + #[values(false, true)] single_css: bool, +) { + // Given: props differ from the factory's arguments and nested callbacks' local inputs. + let source = format!( + "function make(){{return styled.div({rules});}}const Card=make({{theme:{{colors:{{brand:'orange'}}}}}});" + ); + let observation = "JSON.stringify(values(render({target:Card,props:{color:'red'}}),'color'));"; + // When: extracted code executes an ordinary dynamic value in its actual scope. + let observed = execute_in_mode("test.ts", &source, observation, single_css); + // Then: only actual-props theme roots are refused; other values retain their behavior. + assert_eq!(observed, serde_json::json!([expected])); +} diff --git a/libs/extractor/src/styled_source_tests/props_function_generic_self_reference.rs b/libs/extractor/src/styled_source_tests/props_function_generic_self_reference.rs new file mode 100644 index 000000000..ad42ad78f --- /dev/null +++ b/libs/extractor/src/styled_source_tests/props_function_generic_self_reference.rs @@ -0,0 +1,90 @@ +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::distinct("function rules(p:T){return {color:rules.name};}")] +#[case::same_name("function rules(p:rules){return {color:rules.name};}")] +#[case::default("function rules(p:rules,other=rules){return {color:'red'};}")] +#[case::destructured_default("function rules({color=rules.name}:rules){return {color};}")] +#[case::rest("function rules(...p:rules[]){return {color:rules.name};}")] +#[case::nested("function rules(p:rules){return {color:(()=>rules.name)()};}")] +#[case::nested_type("function rules(p){return {color:((p:rules)=>rules.name)(p)};}")] +#[serial] +fn value_self_reference_is_refused_when_type_names_overlap( + #[case] callback: &str, + #[values(false, true)] single_css: bool, +) { + // Given: type bindings cannot shadow the callback's value identity. + reset_class_map(); + reset_file_map(); + let source = + format!("import {{styled}} from '@devup-ui/react';\nconst Card=styled.div({callback});"); + // When: the full extractor lowers the whole-rule callback. + let result = extract( + "generic-self-reference.tsx", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then: the original location reports the value self-reference, not a generic refusal. + let error = match result { + Ok(output) => panic!("value self-reference must be refused: {output:?}"), + Err(error) => error.to_string(), + }; + assert!( + error.starts_with("generic-self-reference.tsx:2:23:"), + "{error}" + ); + assert!(error.contains("cannot use `"), "{error}"); + assert!( + error.contains("callback `rules` refers to itself; recursion is not compiled"), + "{error}" + ); +} + +#[rstest] +#[case::type_only("function rules(p:rules){return {color:'red'};}")] +#[case::type_query("function rules(p:T){return {color:'red'};}")] +#[case::parameter("function rules(rules:rules){return {color:rules.color};}")] +#[case::destructured(concat!("function rules(", "{", "color:rules", "}", ":rules){return ", "{", "color:rules", "}", ";}"))] +#[case::nested_parameter( + "function rules(p:rules){return {color:((rules)=>rules)(p.color)};}" +)] +#[case::nested_name( + "function rules(p:rules){return {color:(function rules(){return p.color;})()};}" +)] +#[case::block( + "function rules(p:rules){return {color:(()=>{const rules=p.color;return rules;})()};}" +)] +#[case::default_shadow( + "function rules(p:rules,other=((rules)=>rules)(0)){return {color:p.color};}" +)] +#[case::rest_shadow("function rules(...rules:rules[]){return {color:rules[0].color};}")] +#[serial] +fn callback_compiles_when_references_are_type_only_or_value_shadowed( + #[case] callback: &str, + #[values(false, true)] single_css: bool, +) { + // Given: only type references or references to genuine local values use this spelling. + reset_class_map(); + reset_file_map(); + let source = + format!("import {{styled}} from '@devup-ui/react';const Card=styled.div({callback});"); + // When: the same full-extractor path processes a supported callback. + let output = extract( + "generic-controls.tsx", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("type-only names and value shadows must compile: {error}")); + // Then: the callback survives lowering and produces actual extracted declarations. + assert!(output.code.contains("function rules<"), "{}", output.code); + assert_ne!(output.styles.len(), 0, "{output:?}"); +} diff --git a/libs/extractor/src/styled_source_tests/props_function_order.rs b/libs/extractor/src/styled_source_tests/props_function_order.rs new file mode 100644 index 000000000..72afee1f1 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/props_function_order.rs @@ -0,0 +1,169 @@ +use super::execute_in_mode; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[serial] +fn callbacks_share_authored_argument_order_when_controllers_and_covered_reads_interleave( + #[values(false, true)] single_css: bool, +) { + // Given: earlier/later getters, two callbacks, an opaque class, and an inactive branch. + let source = "const trace=[];let selected=true;const state={get before(){trace.push('before');return 'orange';},get opaque(){trace.push('class');return 'opaque';},get on(){trace.push('on');return selected;},get margin(){trace.push('margin');return '4px';},get after(){trace.push('after');return 'black';}};const make=input=>styled.div({color:input.before},(p,unused=(trace.push('first'),0))=>({color:(trace.push('covered'),p.color)}),input.opaque,input.on?[{margin:input.margin},{background:'green'}]:[],(p,unused=(trace.push('second'),0))=>({opacity:p.opacity,'&:hover':{color:(trace.push('hover'),p.color)}}),{color:input.after});const Card=make(state);const defined=trace.slice();"; + let observation = r"const first=render({target:Card,props:{color:'red',opacity:0.5}});const firstTrace=trace.slice();trace.length=0;selected=false;const second=render({target:Card,props:{color:'blue',opacity:0.25}});JSON.stringify([defined,firstTrace,trace,values(first,'color'),values(first,'color',0,'&:hover'),values(first,'margin'),values(second,'color'),values(second,'color',0,'&:hover'),values(second,'margin'),values(second,'opacity'),[first,second].map(element=>element.props.className.split(/\s+/).includes('opaque'))]);"; + // When: both branch states execute through the same generated component. + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then: covered authored reads still run, without callback-prepending or inactive leaf reads. + assert_eq!( + observed, + serde_json::json!([ + [], + [ + "before", "first", "covered", "class", "on", "margin", "second", "hover", "after" + ], + [ + "before", "first", "covered", "class", "on", "second", "hover", "after" + ], + ["black"], + ["red"], + ["4px"], + ["black"], + ["blue"], + [], + [0.25], + [true, true] + ]) + ); +} + +#[rstest] +#[serial] +fn repeated_leaf_occurrences_stay_distinct_when_later_rules_cover_the_callback( + #[values(false, true)] single_css: bool, +) { + // Given: the same getter occurs twice, and only its hover declaration survives composition. + let source = "const trace=[];let n=0;const data={get color(){trace.push('color');return ++n===1?'red':'blue';}};const Card=styled.div(({data})=>({color:data.color,'&:hover':{color:data.color}}),{color:'green'});const defined=trace.slice();"; + // When: one actual render evaluates the authored occurrences in object-initializer order. + let observed = execute_in_mode( + "test.ts", + source, + "const element=render({target:Card,props:{data}});JSON.stringify([defined,trace,values(element,'color'),values(element,'color',0,'&:hover')]);", + single_css, + ); + // Then: the covered first read is neither removed nor deduplicated into the surviving read. + assert_eq!( + observed, + serde_json::json!([[], ["color", "color"], ["green"], ["blue"]]) + ); +} + +#[rstest] +#[serial] +fn inherited_vectors_rebind_when_retargeted_extensions_use_authored_capture_names( + #[values(false, true)] single_css: bool, +) { + // Given: inherited attrs and bindings, colliding authored names, and distinct caller props. + let source = "const trace=[];function make(__devupRecord0){const Base=styled.div.attrs({role:'button'})(p=>({color:(trace.push(__devupRecord0),p.tone),margin:p.margin}));const With=Base.withComponent('section');const Child=styled(Base)((p,__devupRecord1=(trace.push('child'),0))=>({opacity:p.opacity}));const Again=Child.withComponent('aside');return {With,Again};}const {With,Again}=make('base');const defined=trace.slice();const App=ref=>jsx(Again,{tone:'blue',margin:3,opacity:0.5,ref,children:'child',className:'caller',style:{color:'olive'},forwardedAs:'header'});"; + let observation = r"const first=render({target:With,props:{tone:'red',margin:2}});const second=render(App(7));JSON.stringify([defined,trace,first.target,second.target,values(first,'color'),values(first,'margin'),values(second,'color'),values(second,'margin'),values(second,'opacity'),second.props.role,second.props.ref,second.props.children,second.props.as,second.props.style.color,second.props.className.split(/\s+/).includes('caller'),'tone' in second.props,slots(second)]);"; + // When: a retarget and a retargeted extension each render inherited callback captures. + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then: initializers and index reads are renamed together; no private CSS transport leaks. + assert_eq!( + observed, + serde_json::json!([ + [], + ["base", "base", "child"], + "section", + "aside", + ["red"], + ["2px"], + ["blue"], + ["3px"], + [0.5], + "button", + 7, + "child", + "header", + "olive", + true, + false, + [] + ]) + ); +} + +#[rstest] +#[serial] +fn empty_conditional_arrays_still_run_between_callbacks_when_neither_branch_has_rules( + #[values(false, true)] single_css: bool, +) { + // Given: an empty choice still has a getter between two otherwise static callbacks. + let source = "const trace=[];const state={get on(){trace.push('on');return false;}};const make=input=>styled.div((p,unused=(trace.push('first'),0))=>({}),input.on?[]:[],(p,unused=(trace.push('last'),0))=>({color:'red'}));const Card=make(state);const defined=trace.slice();"; + // When: the original callback and controller schedules execute in one render. + let observed = execute_in_mode( + "test.ts", + source, + "const element=render({target:Card,props:{}});JSON.stringify([defined,trace,values(element,'color')]);", + single_css, + ); + // Then: no empty-vector or empty-array optimization erases observable work or reorders it. + assert_eq!( + observed, + serde_json::json!([[], ["first", "on", "last"], ["red"]]) + ); +} + +#[rstest] +#[serial] +fn dependent_record_fallbacks_follow_inherited_and_own_vectors_when_presence_changes( + #[values(false, true)] single_css: bool, +) { + // Given: a conditional record override depends on earlier callback-vector leaves. + let source = "const trace=[];let selected=true;const state={get on(){trace.push('on');return selected;},get color(){trace.push('after');return 'blue';}};const make=input=>{const Base=styled.div((p,unused=(trace.push('base'),0))=>({color:p.base}));const Child=styled(Base)((p,unused=(trace.push('own'),0))=>({color:p.tone,margin:p.margin}),{...(input.on?{color:input.color}:{})});return Child.withComponent('section');};const Card=make(state);const defined=trace.slice();"; + let observation = "const first=render({target:Card,props:{base:'orange',tone:'red',margin:2}});const firstTrace=trace.slice();trace.length=0;selected=false;const second=render({target:Card,props:{base:'orange',tone:'green',margin:3}});JSON.stringify([defined,firstTrace,trace,values(first,'color'),values(second,'color'),values(first,'margin'),values(second,'margin'),second.target]);"; + // When: both record-presence states render through the same retargeted extension. + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then: dependent record bindings run last and fall back to this render's own vector. + assert_eq!( + observed, + serde_json::json!([ + [], + ["base", "own", "on", "after"], + ["base", "own", "on"], + ["blue"], + ["green"], + ["2px"], + ["3px"], + "section" + ]) + ); +} + +#[rstest] +#[serial] +fn static_callback_vectors_keep_existing_css_slots_when_extensions_are_retargeted( + #[values(false, true)] single_css: bool, +) { + // Given: static rules still carry observable per-render work through an existing CSS slot. + let source = "const trace=[];const Base=styled.div((p,unused=(trace.push('base'),0))=>({color:'red'}));const Child=styled(Base)({padding:1});const Card=Child.withComponent('section');const defined=trace.slice();const App=ref=>jsx(Card,{css:{color:'blue'},ref,children:'child',className:'caller'});"; + let observation = r"const plain=render({target:Card,props:{}});const raw=App(7);const composed=render(raw);JSON.stringify([defined,trace,raw.target===Card,slots(raw).length,values(plain,'color'),values(composed,'color'),values(composed,'padding'),composed.target,composed.props.ref,composed.props.children,slots(composed),composed.props.className.split(/\s+/).includes('caller')]);"; + // When: a plain render and an authored CSS-prop render share inherited bindings. + let observed = execute_in_mode("test.ts", source, observation, single_css); + // Then: the existing private slot is consumed without bypassing the empty-vector invocation. + assert_eq!( + observed, + serde_json::json!([ + [], + ["base", "base"], + true, + 1, + ["red"], + ["blue"], + ["4px"], + "section", + 7, + "child", + [], + true + ]) + ); +} diff --git a/libs/extractor/src/styled_source_tests/props_function_self_reference.rs b/libs/extractor/src/styled_source_tests/props_function_self_reference.rs new file mode 100644 index 000000000..e16d26a24 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/props_function_self_reference.rs @@ -0,0 +1,127 @@ +use super::super::execute_in_mode; +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::recursive(concat!("function rules(p){return {color:p.done?'red':rules(", "{", "done:true", "}", ").color};}"))] +#[case::name("function rules(p){return {color:rules.name==='rules'?p.color:'orange'};}")] +#[case::length("function rules(p){return {opacity:rules.length};}")] +#[case::default("function rules(p,other=rules){return {color:p.color};}")] +#[case::destructured_default("function rules({color=rules.name}){return {color};}")] +#[case::nested_arrow("function rules(p){return {color:(()=>rules(p).color)()};}")] +#[case::nested_function( + "function rules(p){return {color:(function(){return rules(p).color;})()};}" +)] +#[case::leaf_capture("function rules(p){return {color:()=>rules(p).color};}")] +#[case::shorthand("function rules(p){return {color:({rules}).rules.name};}")] +#[case::computed_key("function rules(p){return {color:({[rules.name]:p.color}).rules};}")] +#[case::shadow_ends( + "function rules(p){return {color:(()=>{{const rules='orange';}return rules.name;})()};}" +)] +#[case::catch_ends( + "function rules(p){return {color:(()=>{try{}catch(rules){}return rules.name;})()};}" +)] +#[case::loop_ends( + "function rules(p){return {color:(()=>{for(const rules of []){}return rules.name;})()};}" +)] +#[case::switch_ends( + "function rules(p){return {color:(()=>{switch(0){case 0:const rules=0;break;}return rules.name;})()};}" +)] +#[case::body_var_does_not_shadow_default( + "function rules(p,other=rules){return {color:(()=>{var rules='orange';return rules;})()};}" +)] +#[serial] +fn named_whole_rule_self_references_are_located_errors( + #[case] callback: &str, + #[values(false, true)] single_css: bool, +) { + // Given: the reference resolves to the callback's own function binding. + reset_class_map(); + reset_file_map(); + let source = + format!("import {{styled}} from '@devup-ui/react';\nconst Card=styled.div({callback});"); + // When: the immediate-object whole-rule callback is lowered. + let result = extract( + "self-reference.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then: the original callback location and binding identify the unsupported construct. + let error = match result { + Ok(output) => panic!("self-reference must be refused: {output:?}"), + Err(error) => error.to_string(), + }; + assert!(error.starts_with("self-reference.ts:2:23:"), "{error}"); + assert!(error.contains("cannot use `"), "{error}"); + assert!(error.contains("function rules("), "{error}"); + assert!( + error.contains("callback `rules` refers to itself; recursion is not compiled"), + "{error}" + ); +} + +#[rstest] +#[case::named_nonself("function rules(p){return {color:p.color};}")] +#[case::parameter("function rules(rules){return {color:rules.color};}")] +#[case::destructured_parameter(concat!("function rules(", "{", "color:rules", "}", "){return {color:rules};}"))] +#[case::member_name("function rules(p){return {color:({rules:p.color}).rules};}")] +#[case::method_name("function rules(p){return {color:({rules(){return p.color;}}).rules()};}")] +#[case::nested_parameter("function rules(p){return {color:((rules)=>rules)(p.color)};}")] +#[case::nested_function_name( + "function rules(p){return {color:(function rules(){return p.color;})()};}" +)] +#[case::block_let("function rules(p){return {color:(()=>{let rules=p.color;return rules;})()};}")] +#[case::block_var("function rules(p){return {color:(()=>{{var rules=p.color;}return rules;})()};}")] +#[case::hoisted_var( + "function rules(p){return {color:(()=>{rules=p.color;{var rules;}return rules;})()};}" +)] +#[case::loop_binding( + "function rules(p){return {color:(()=>{for(const rules of [p.color])return rules;})()};}" +)] +#[case::switch_binding( + "function rules(p){return {color:(()=>{switch(0){case 0:const rules=p.color;return rules;}})()};}" +)] +#[case::default_local("function rules(p,other=((rules)=>rules)(0)){return {color:p.color};}")] +#[case::ordinary_property( + "{color:function rules(p){'use strict';return rules.length===1&&rules.name==='rules'?p.color:'orange';}}" +)] +#[serial] +fn unrelated_bindings_and_property_names_keep_their_values( + #[case] callback: &str, + #[values(false, true)] single_css: bool, +) { + // Given: same-spelled identifiers belong to local bindings or property names. + let source = format!("const Card=styled.div({callback});"); + // When: the generated renderer evaluates two different props objects. + let observed = execute_in_mode( + "test.ts", + &source, + "const first=render({target:Card,props:{color:'red'}});const second=render({target:Card,props:{color:'blue'}});JSON.stringify([values(first,'color'),values(second,'color')]);", + single_css, + ); + // Then: no textual-name refusal or semantic retargeting changes the local values. + assert_eq!(observed, serde_json::json!([["red"], ["blue"]])); +} + +#[rstest] +#[serial] +fn catch_binding_keeps_its_local_value_without_referring_to_the_callback( + #[values(false, true)] single_css: bool, +) { + // Given: a catch parameter, not the callback binding, owns the reference. + let source = "const Card=styled.div(function rules(p){return {color:(()=>{try{throw p.color;}catch(rules){return rules;}})()};});"; + // When: the compiled callback runs with two distinct actual props. + let observed = execute_in_mode( + "test.ts", + source, + "const first=render({target:Card,props:{color:'red'}});const second=render({target:Card,props:{color:'blue'}});JSON.stringify([values(first,'color'),values(second,'color')]);", + single_css, + ); + // Then: catch-local references retain their authored value, not the function binding. + assert_eq!(observed, serde_json::json!([["red"], ["blue"]])); +} diff --git a/libs/extractor/src/styled_source_tests/props_function_theme.rs b/libs/extractor/src/styled_source_tests/props_function_theme.rs new file mode 100644 index 000000000..41978a2bc --- /dev/null +++ b/libs/extractor/src/styled_source_tests/props_function_theme.rs @@ -0,0 +1,131 @@ +use super::execute_in_mode; +use crate::{ExtractOption, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case::arrow( + "p=>({color:p['theme'].colors.primary,margin:p.theme.space[2]})", + false +)] +#[case::block_arrow( + "p=>{return {color:p.theme.colors.primary,margin:p.theme.space[2]};}", + false +)] +#[case::normal( + "function(p){return {color:p.theme.colors.primary,margin:p['theme'].space[2]};}", + false +)] +#[case::extra_default( + "(p,unused=(trace.push('default'),0))=>({color:p.theme.colors.primary,margin:p.theme.space[2]})", + true +)] +#[case::renamed_default( + "({theme:palette},unused=(trace.push('default'),0))=>({color:palette['colors'].primary,margin:palette.space[2]})", + true +)] +#[case::normal_renamed( + concat!("function(", "{", "theme:palette", "}", ",unused=(trace.push('default'),0)){return {color:palette.colors.primary,margin:palette.space[2]};}"), + true +)] +#[serial] +fn mapped_theme_leaves_stay_static_when_original_parameter_work_must_run( + #[case] callback: &str, + #[case] default_runs: bool, + #[values(false, true)] single_css: bool, +) { + // Given: static theme paths do not need a runtime theme object, but defaults still have effects. + let source = + format!("const trace=[];const Card=styled.div({callback});const defined=trace.slice();"); + let observation = "const first=render({target:Card,props:{}});const second=render({target:Card,props:{}});JSON.stringify([defined,trace,[first,second].map(element=>atoms(element).map(rule=>rule.slice(0,2)).sort())]);"; + // When: the generated original callback executes on each render with an empty dynamic vector. + let observed = execute_in_mode("test.ts", &source, observation, single_css); + let trace = if default_runs { + serde_json::json!(["default", "default"]) + } else { + serde_json::json!([]) + }; + let atoms = serde_json::json!([ + ["color", "var(--colors-primary)"], + ["margin", "var(--space-2)"] + ]); + // Then: declarations remain static CSS variables and parameter effects are neither lost nor repeated. + assert_eq!(observed, serde_json::json!([[], trace, [atoms, atoms]])); +} + +#[rstest] +#[case::property_arrow("{color:(p,unused=p.theme.colors[key])=>'red'}")] +#[case::property_normal( + "{color:function({theme:palette},unused=palette.colors[key]){return 'red';}}" +)] +#[case::whole_arrow("(p,unused=p['theme'].colors[key])=>({color:'red'})")] +#[case::whole_normal(concat!("function(", "{", "theme:palette", "}", ",unused=palette.colors[key]){return {color:'red'};}"))] +#[case::nested_default("({theme:palette,color=palette.colors[key]})=>({color:'red'})")] +#[case::property_nested_default("{color:({theme:palette,color=palette.colors[key]})=>'red'}")] +#[case::ordinary_mapper_must_keep_effects("{color:(p,unused=effect())=>p.theme.colors.primary}")] +#[case::ordinary_mapper_binding_default( + "{color:({theme:palette,color=effect()})=>palette.colors.primary}" +)] +#[serial] +fn parameter_theme_reads_keep_located_refusals_when_extra_formals_or_defaults_hide_them( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given: a first-props-root theme read outside the conservative whole-function mapper. + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{styled}} from '@devup-ui/react';\nconst make=(key,effect)=>styled.div({rules});" + ); + // When: the shared theme scanner inspects both the original body and parameter initializers. + let result = extract( + "props-default-theme.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ); + // Then: the original located theme guard is retained rather than emitting a runtime theme read. + let error = match result { + Ok(output) => panic!("parameter theme work must not escape mapping: {output:?}"), + Err(error) => error.to_string(), + }; + assert!(error.starts_with("props-default-theme.ts:2:"), "{error}"); + assert!( + error.contains("a theme read must be a path of names or literal keys"), + "{error}" + ); +} + +#[rstest] +#[case::number(("2", serde_json::json!("2px"), serde_json::json!(2)))] +#[case::string(("'3em'", serde_json::json!("3em"), serde_json::json!("3em")))] +#[case::zero(("0", serde_json::json!(0), serde_json::json!(0)))] +#[case::negative(("-2", serde_json::json!("-2px"), serde_json::json!(-2)))] +#[serial] +fn whole_rule_numbers_keep_the_existing_conversion_when_ordinary_property_callbacks_do_not( + #[case] fixture: (&str, serde_json::Value, serde_json::Value), + #[values(false, true)] single_css: bool, +) { + // Given: whole-rule conversion must not spread to ordinary authored property callbacks. + let (input, whole_margin, ordinary_margin) = fixture; + let source = "const trace=[];const Whole=styled.div(p=>({margin:p.margin,opacity:p.opacity,padding:1}));const Ordinary=styled.div({margin:(p,unused=(trace.push('ordinary'),0))=>p.margin});"; + let observation = format!( + "const whole=render({{target:Whole,props:{{margin:{input},opacity:0.5}}}});const ordinary=render({{target:Ordinary,props:{{margin:{input}}}}});JSON.stringify([trace,values(whole,'margin'),values(whole,'opacity'),values(whole,'padding'),values(ordinary,'margin')]);" + ); + // When: both supported callback forms execute with the same input. + let observed = execute_in_mode("test.ts", source, &observation, single_css); + // Then: dynamic nonzero px, unitless, static scale, zero, and ordinary values keep their old domains. + assert_eq!( + observed, + serde_json::json!([ + ["ordinary"], + [whole_margin], + [0.5], + ["4px"], + [ordinary_margin] + ]) + ); +} diff --git a/libs/extractor/src/theme_callback.rs b/libs/extractor/src/theme_callback.rs new file mode 100644 index 000000000..805f70acc --- /dev/null +++ b/libs/extractor/src/theme_callback.rs @@ -0,0 +1,29 @@ +//! Typed callback parts shared by theme-read analysis. + +use oxc_ast::ast::{ArrowFunctionBody, Expression, FormalParameters, FunctionBody}; + +pub(super) enum Body<'r, 'a> { + Arrow(&'r ArrowFunctionBody<'a>), + Function(Option<&'r FunctionBody<'a>>), +} + +pub(super) struct Callback<'r, 'a> { + pub params: &'r FormalParameters<'a>, + pub body: Body<'r, 'a>, +} + +impl<'r, 'a> Callback<'r, 'a> { + pub fn read(expression: &'r Expression<'a>) -> Option { + match expression { + Expression::ArrowFunctionExpression(arrow) => Some(Self { + params: &arrow.params, + body: Body::Arrow(&arrow.body), + }), + Expression::FunctionExpression(function) => Some(Self { + params: &function.params, + body: Body::Function(function.body.as_deref()), + }), + _ => None, + } + } +} diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index ecdaf46d6..a75c8e187 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -989,6 +989,14 @@ pub(super) fn css_prop_error(element: &str, code: &str, requirement: &str) -> St pub(super) const CSS_PROP_VALUE: &str = "it must be a style object, CSS text, a class `css()` gives, or a function of the theme giving one, or an array or condition of them"; +/// A style object declared below the top level that styles read, and code +/// elsewhere reads too +pub(super) fn local_style_error(name: &str) -> String { + format!( + "Cannot read `{name}` as styles at build time: it is also read elsewhere, where code may change it, so use it only as styles, or declare it with `const` at the top level of the module" + ) +} + pub(super) const LOCAL_STYLES: &str = "a style object it composes must be written in it, or declared with `const` at the top level of the module, where the build reads it"; pub(super) const CLASS_NAMES_CHILD: &str = diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 4e0eb49a4..2097d46bf 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -29,9 +29,12 @@ use crate::extractor::{ FORWARD_REF, Naming, StyledDefinition, StyledExtraction, extended, extract_style_from_styled, read_forward, take_styled_modifiers, with_component, }, + rule_array_evaluation::{self, composes_array_under_condition}, styled_capability::StyledCapability, styled_css_slot::{CssPropLowering, StyledContext}, styled_jsx::StyledJsx, + styled_record_evaluation::{Evaluations, bind_render}, + styled_rule_arrays::refused_controllers, }; use crate::gen_class_name::{gen_class_names, merge_expression_for_class_name}; use crate::gen_style::gen_styles; @@ -300,6 +303,9 @@ pub struct DevupVisitor<'a> { /// `css` prop cannot compose as a class, besides the top-level constants /// the build reads in its place local_styles: FxHashSet, + /// The bindings below the top level of the module holding an object or + /// array, which no style reads as the styles it holds + local_objects: FxHashSet, } /// A part of `stylex.props(...)`: the classes of each key a namespace sets, @@ -433,9 +439,9 @@ impl<'a> DevupVisitor<'a> { }) .collect(); if !flattened - && !arguments - .iter() - .any(|argument| self.reads_known_styles(argument)) + && !arguments.iter().any(|argument| { + self.reads_known_styles(argument) || composes_array_under_condition(argument) + }) { return None; } @@ -445,13 +451,94 @@ impl<'a> DevupVisitor<'a> { } self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); - let (result, known) = self.composed_class::(call.span.start, parts); + self.local_arguments("css", &call.arguments); + let error_count = self.errors.len(); + let (mut result, mut known) = self.composed_class::(call.span.start, parts); + if self.errors.len() == error_count + && arguments + .iter() + .any(|argument| composes_array_under_condition(argument)) + { + let source = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in( + arguments.iter().map(|argument| { + argument + .clone_in_with_semantic_ids(self.ast.allocator()) + .into() + }), + &self.ast, + ), + &self.ast, + ); + let mut reads = Evaluations::new(&self.ast, readable_code(&source)); + if let Some(prepared) = rule_array_evaluation::prepare(&mut reads, &source, |value| { + self.style_values.styles(value).is_some() + }) { + let mut captured = Vec::new(); + if self + .known_parts(&prepared, &mut captured, Text::Classes) + .is_some() + { + let bindings = reads.bindings; + (result, known) = self.composed_class::(call.span.start, captured); + result = bind_render(&self.ast, bindings, result); + } + } + } if let Some(known) = known { self.css_styles = Some((call.span.start, known)); } Some(result) } + /// Whether `expression` builds a styled component: `styled.div(...)`, + /// `styled("div")(...)`, `styled("div", {...})` or their CSS text forms + fn is_styled_call(&self, expression: &Expression<'a>) -> bool { + if !self.bindings.may_style() { + return false; + } + let (tag_or_call, argument_count) = match expression { + Expression::TaggedTemplateExpression(tag) => (Some(&tag.tag), 0), + Expression::CallExpression(call) => (Some(&call.callee), call.arguments.len()), + _ => (None, 0), + }; + let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) else { + return false; + }; + if let Expression::StaticMemberExpression(member) = tag_or_call { + self.bindings.is_styled(&member.object) + } else if let Expression::CallExpression(call) = tag_or_call { + self.bindings.is_styled(&call.callee) + } else if let Expression::Identifier(_) = tag_or_call { + // styled("div", { ... }) puts the tag in the arguments, so the callee is + // the bare identifier. One argument is the curried creator `styled("div")`, + // which only becomes a component once its result is called. + self.bindings.is_styled(tag_or_call) && argument_count == 2 + } else { + false + } + } + + /// Report `attrs` chained after the styles of a styled component, which + /// neither Emotion nor styled-components take + fn chained_attrs(&mut self, expression: &Expression<'a>) { + if let Expression::CallExpression(call) = expression + && let Expression::StaticMemberExpression(member) = unwrap_syntax_only(&call.callee) + && member.property.name == "attrs" + && self.is_styled_call(unwrap_syntax_only(&member.object)) + { + self.errors.push(( + call.span.start, + build_time_error( + "styled", + &readable_code(expression), + "chain `.attrs` before the styles, as `styled.div.attrs({ ... })({ ... })`, which neither Emotion nor styled-components take after them", + ), + )); + } + } + /// Whether `callee` is a call of `css()` fn is_css(&self, callee: &Expression<'a>) -> bool { self.util_type(callee) @@ -951,6 +1038,7 @@ impl<'a> DevupVisitor<'a> { css_prop: CssProp::Off, compiled_css_prop: false, local_styles: FxHashSet::default(), + local_objects: FxHashSet::default(), } } @@ -1396,6 +1484,34 @@ impl<'a> DevupVisitor<'a> { } } + /// Whether `part` reads a binding `local_objects` holds + fn reads_local_objects(&self, part: &Expression<'a>) -> bool { + self.style_values + .symbol(part) + .is_some_and(|symbol| self.local_objects.contains(&symbol)) + } + + /// Report the arguments of `api` that read an object or array declared + /// below the top level, which the build did not read as styles: it would + /// be taken for a class + fn local_arguments(&mut self, api: &str, arguments: &[Argument<'a>]) { + if self.local_objects.is_empty() { + return; + } + for argument in arguments { + let expression = match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + if let Some(part) = self.css_part(expression, &Self::reads_local_objects) { + self.errors.push(( + part.span().start, + build_time_error(api, &readable_code(part), LOCAL_STYLES), + )); + } + } + } + /// Whether `part` reads a binding `local_styles` holds fn reads_local_styles(&self, part: &Expression<'a>) -> bool { let mut root = part; @@ -1857,6 +1973,7 @@ impl<'a> DevupVisitor<'a> { let offset = call.span.start; self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); + self.local_arguments("css", &call.arguments); let mut parts = Vec::new(); *it = if self.known_parts(it, &mut parts, Text::Classes).is_some() { self.composed_class::(offset, parts).0 @@ -3129,6 +3246,17 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Some(error) = forward_error { self.errors.push(error); } + self.chained_attrs(it); + let mut refused_array_controllers = Vec::new(); + if self.is_styled_call(it) + && let Expression::CallExpression(call) = &*it + { + for argument in &call.arguments { + if let Some(part) = argument.as_expression() { + refused_controllers(part, &mut refused_array_controllers); + } + } + } for attr in &mut attrs { let reads = self.bindings.lowered_reads(attr); self.report_reads(reads); @@ -3158,80 +3286,56 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.report_lowered_reads(it); // Handle styled function calls - if self.bindings.may_style() { - let (tag_or_call, argument_count) = match it { - Expression::TaggedTemplateExpression(tag) => (Some(&tag.tag), 0), - Expression::CallExpression(call) => (Some(&call.callee), call.arguments.len()), - _ => (None, 0), - }; - let bindings = &self.bindings; - - let is_styled = tag_or_call - .map(unwrap_syntax_only) - .is_some_and(|tag_or_call| match tag_or_call { - Expression::StaticMemberExpression(member) => { - bindings.is_styled(&member.object) - } - Expression::CallExpression(call) => bindings.is_styled(&call.callee), - // styled("div", { ... }) puts the tag in the arguments, so the callee is - // the bare identifier. One argument is the curried creator `styled("div")`, - // which only becomes a component once its result is called. - Expression::Identifier(_) => { - bindings.is_styled(tag_or_call) && argument_count == 2 - } - _ => false, - }); - - if is_styled { - self.style_values.read_in(&self.ast, it); - if let Expression::CallExpression(call) = &*it { - self.unknown_arguments("styled", &call.arguments); - self.changed_arguments("styled", &call.arguments); - } - let start = it.span().start; - let markers = self - .pending_marker - .take_if(|(at, _)| *at == start) - .map(|(_, markers)| self.marker_classes(&markers)) - .unwrap_or_default(); - let inherited = extended(it).and_then(|base| { - self.definition_of(base) - .filter(|definition| definition.extendable()) - .map(|definition| definition.used_as(&self.ast, base)) - }); - let StyledExtraction { - result, - expression, - errors, - definition, - } = extract_style_from_styled( - &self.ast, - it, - Naming { - split_filename: self.split_filename.as_deref(), - markers: &markers, - }, - &|expression| self.bindings.kind(expression), - (&attrs, forward), - inherited.as_ref(), - StyledContext { - jsx: &mut self.styled_jsx, - capability: self.styled_capability.as_ref(), - }, - ); - self.errors.extend(errors); - self.styles.extend( - result - .styles - .into_iter() - .flat_map(ExtractStyleProp::into_extract), - ); - if definition.is_some() { - self.forwards_refs = true; - } - *it = expression; - self.pending_styled = definition.map(|definition| (start, definition)); + if self.is_styled_call(it) { + self.style_values.read_in(&self.ast, it); + if let Expression::CallExpression(call) = &*it { + self.unknown_arguments("styled", &call.arguments); + self.changed_arguments("styled", &call.arguments); + self.local_arguments("styled", &call.arguments); + } + let start = it.span().start; + let markers = self + .pending_marker + .take_if(|(at, _)| *at == start) + .map(|(_, markers)| self.marker_classes(&markers)) + .unwrap_or_default(); + let inherited = extended(it).and_then(|base| { + self.definition_of(base) + .filter(|definition| definition.extendable()) + .map(|definition| definition.used_as(&self.ast, base)) + }); + let StyledExtraction { + result, + expression, + errors, + definition, + } = extract_style_from_styled( + &self.ast, + it, + Naming { + split_filename: self.split_filename.as_deref(), + markers: &markers, + }, + &|expression| self.bindings.kind(expression), + (&attrs, forward, &refused_array_controllers), + inherited.as_ref(), + StyledContext { + jsx: &mut self.styled_jsx, + capability: self.styled_capability.as_ref(), + }, + ); + self.errors.extend(errors); + self.styles.extend( + result + .styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract), + ); + if definition.is_some() { + self.forwards_refs = true; } + *it = expression; + self.pending_styled = definition.map(|definition| (start, definition)); } // `Component.withComponent(target)` on a styled component the file @@ -3767,6 +3871,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } let offset = call.span.start; let is_css = util_type.is_css(); + self.local_arguments(util_type.api(), &call.arguments); if is_css { self.unknown_arguments(util_type.api(), &call.arguments); self.changed_arguments(util_type.api(), &call.arguments); @@ -4413,6 +4518,18 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { self.local_styles.insert(symbol); } + if matches!( + it.init.as_ref().map(unwrap_syntax_only), + Some(Expression::ObjectExpression(_) | Expression::ArrayExpression(_)) + ) && let Some(symbol) = it + .id + .get_binding_identifier() + .and_then(|id| id.symbol_id.get()) + .filter(|symbol| self.style_values.is_local(*symbol)) + { + self.local_objects.insert(symbol); + } + let marked = styled_binding .zip(start) .zip(it.id.get_binding_identifier().map(|id| id.name.to_string()))