diff --git a/.changepacks/changepack_log_emotion_css_prop.json b/.changepacks/changepack_log_emotion_css_prop.json new file mode 100644 index 000000000..71e82e1df --- /dev/null +++ b/.changepacks/changepack_log_emotion_css_prop.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Emotion's css prop compiles at build time while @emotion/react is aliased: on tags and Devup UI components always, and on every element once the file imports @emotion/react or @emotion/styled or names @emotion/react in a @jsxImportSource pragma; jsx, jsxs and jsxDEV from @emotion/react/jsx-runtime (or jsx-dev-runtime) and jsx from @emotion/react compile it too, so libraries built with Emotion's JSX runtime do as well. The prop becomes the element's className, with CSS variables in its style for values only the runtime gives: arrays and conditions compose with a later part replacing what an earlier one sets, css() classes the file knows compose by their styles, strings and templates are CSS text, css`` mixins split the text around them, a function of the theme reads theme.a.b as var(--a-b), unitless numbers are px as in Emotion (constants included), and a className holding known css() classes overrides the prop as Emotion's registered classes do. A styled component the file defines renders its tag in the element's place when the prop overrides its styles and it renders a tag with no attrs or props read and the element has no spread, as or forwardedAs. Emotion's JSX runtime imports become react/jsx-runtime, the pragma names react, and jsx comes from @devup-ui/react/compat (React's createElement); @devup-ui/react/compat/css-prop types the prop on React.Attributes. New build errors, each with file:line:column and the code: a css prop part the build cannot read (a call, an element), a style object declared inside a function or with let, a binding only running the module gives or code changes, a theme function that does more than return rules or reads the theme other than as theme.a.b in a value, an interpolation CSS text cannot place or a mixin inside a nested rule, and a css prop overriding a styled component's styles where its tag cannot be rendered in place. Composing css() with a part that reads a known binding (such as a keyframes name) now reads its value", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_emotion_css_prop_contracts.json b/.changepacks/changepack_log_emotion_css_prop_contracts.json new file mode 100644 index 000000000..1cabcb4a6 --- /dev/null +++ b/.changepacks/changepack_log_emotion_css_prop_contracts.json @@ -0,0 +1,8 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/react/package.json": "Patch" + }, + "note": "Recognize Emotion jsxImportSource eligibility with the same exact header rules used for rewriting, preserve authored first-use cascade-layer order while composing css props, retain exact computed theme keys, and keep Emotion numeric-length pixel semantics when constant rule arrays are inlined. Unitless values and zero retain their existing behavior. Every valid css-prop form remains supported; invalid empty JSX css expressions retain main's located syntax error. No new build-error category is introduced.", + "date": "2026-10-18T00:00:00.000Z" +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index d1ee23179..49d620cee 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -60,14 +60,14 @@ pub(crate) fn has_build_time_values( option: &ExtractOption, resolver: Option<&ModuleResolver>, ) -> bool { - let (code, _) = crate::import_alias_visit::transform_import_aliases_with_edits( + let aliased = crate::import_alias_visit::transform_import_aliases_with_edits( code, filename, &option.package, &option.import_aliases, ); let allocator = Allocator::default(); - let Some(mut program) = parse(&allocator, filename, &code) else { + let Some(mut program) = parse(&allocator, filename, &aliased.code) else { return false; }; let inlined = crate::imported_constants::inline_constants( @@ -76,6 +76,7 @@ pub(crate) fn has_build_time_values( filename, option, resolver, + aliased.css_prop, ); let changes = crate::imported_constants::ChangeCheck::new(&program, filename, option, resolver); !find( diff --git a/libs/extractor/src/composition.rs b/libs/extractor/src/composition.rs index 44dae55a6..b59abb6c6 100644 --- a/libs/extractor/src/composition.rs +++ b/libs/extractor/src/composition.rs @@ -173,6 +173,13 @@ impl<'a> Composition<'a> { self.entries.push((key, choice)); } + /// A class applying after the composed parts, whose styles `values` the + /// build knows: what it sets replaces what they set + pub fn cover(&mut self, values: &[ExtractStyleValue]) { + let keys: Vec = values.iter().map(CascadeKey::of).collect(); + self.entries.retain(|(key, _)| !keys.contains(key)); + } + /// The composed styles, for class names and the stylesheet #[must_use] pub fn into_props(self) -> Vec> { @@ -261,6 +268,41 @@ pub fn set_prop_order(prop: &mut ExtractStyleProp<'_>, order: u8) { } } +/// Whether `later` may set what `earlier` sets, so the order of their classes +/// decides which applies: a style whose key the build cannot tell may +pub fn overlaps(earlier: &[ExtractStyleProp<'_>], later: &[ExtractStyleProp<'_>]) -> bool { + match (keys(earlier), keys(later)) { + (Some(earlier), Some(later)) => later.iter().any(|key| earlier.contains(key)), + _ => true, + } +} + +/// The keys `props` set, `None` when a style chosen at runtime has none; +/// classes only the runtime gives compose at runtime, so they set none here +fn keys(props: &[ExtractStyleProp<'_>]) -> Option> { + let mut keys = Vec::new(); + for prop in props { + match prop { + ExtractStyleProp::Static(value) => keys.push(CascadeKey::of(value)), + ExtractStyleProp::StaticArray(props) => keys.extend(self::keys(props)?), + ExtractStyleProp::Conditional { + consequent, + alternate, + .. + } => { + for side in [consequent, alternate].into_iter().flatten() { + keys.extend(self::keys(std::slice::from_ref(side.as_ref()))?); + } + } + ExtractStyleProp::Expression { .. } | ExtractStyleProp::Unreadable { .. } => {} + ExtractStyleProp::Enum { .. } | ExtractStyleProp::MemberExpression { .. } => { + return None; + } + } + } + Some(keys) +} + /// Whether every style `prop` holds has a key, so a condition around it can be /// applied key by key fn keyed(prop: &ExtractStyleProp<'_>) -> bool { diff --git a/libs/extractor/src/css_prop.rs b/libs/extractor/src/css_prop.rs new file mode 100644 index 000000000..d8cead515 --- /dev/null +++ b/libs/extractor/src/css_prop.rs @@ -0,0 +1,752 @@ +//! Emotion's `css` prop: styles an element takes in place of a class name. +//! The build composes them like `css()` and gives the element the classes and +//! CSS variables they compile to, so no runtime reads the prop. + +use std::collections::HashMap; + +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{ + Argument, ArrowFunctionBody, BinaryOperator, BindingPattern, CallExpression, Expression, + FormalParameters, FunctionBody, ImportDeclarationSpecifier, JSXAttributeItem, JSXElementName, + ObjectPropertyKind, Program, Statement, Str, TemplateElement, TemplateElementValue, + TemplateLiteral, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{VisitMut, walk_mut}; +use oxc_span::SPAN; +use rustc_hash::FxHashSet; + +use crate::ImportAlias; +use crate::css_utils::{Place, interpolation_place}; +use crate::utils::{ + binding_root, get_key_by_literal_expression, get_string_by_literal_expression, + unwrap_syntax_only, +}; + +/// The package whose alias makes elements take the `css` prop +pub(crate) const EMOTION_REACT: &str = "@emotion/react"; + +/// Which elements of a file take Emotion's `css` prop +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CssProp { + /// No element: Emotion is not aliased, or the file writes no `css` prop + #[default] + Off, + /// Tags and Devup UI components, which take no `css` prop of their own + Elements, + /// Every element, as the file shows it uses Emotion + Everywhere, +} + +impl CssProp { + /// Which elements of `code` take the prop, with the aliases the build + /// applies, and whether the file shows it uses Emotion + pub(crate) fn of( + import_aliases: &HashMap, + code: &str, + uses_emotion: bool, + ) -> Self { + if !import_aliases.contains_key(EMOTION_REACT) || !may_take_css_prop(code) { + Self::Off + } else if uses_emotion { + Self::Everywhere + } else { + Self::Elements + } + } + + /// Whether the element `name` takes the prop; `devup` tells a Devup UI + /// component + pub(crate) fn takes(self, name: &JSXElementName<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match name { + JSXElementName::Identifier(_) | JSXElementName::NamespacedName(_) => true, + name => crate::imported_constants::jsx_root(name).is_some_and(devup), + }, + } + } + + /// Whether the element a `jsx()` call builds from `element` takes the prop + pub(crate) fn takes_type(self, element: &Expression<'_>, devup: impl Fn(&str) -> bool) -> bool { + match self { + Self::Off => false, + Self::Everywhere => true, + Self::Elements => match unwrap_syntax_only(element) { + Expression::StringLiteral(_) => true, + element => binding_root(element).is_some_and(devup), + }, + } + } +} + +/// What in a program takes the `css` prop: the elements `css_prop` tells, and +/// the `jsx()` calls building them +pub(crate) struct CssTakers<'s> { + pub css_prop: CssProp, + /// Local names of the functions building elements from a type and props + jsx: FxHashSet<&'s str>, +} + +impl<'s> CssTakers<'s> { + /// The elements `program` gives a `css` prop, as `css_prop` tells; + /// `compat` is the entry absorbing Emotion's own `jsx` + pub(crate) fn new(program: &Program<'s>, css_prop: CssProp, compat: &str) -> Self { + let mut jsx = FxHashSet::default(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement { + for specifier in import.specifiers.iter().flatten() { + if let ImportDeclarationSpecifier::ImportSpecifier(specifier) = specifier + && is_jsx_function(&import.source.value, &specifier.imported.name(), compat) + { + jsx.insert(specifier.local.name.as_str()); + } + } + } + } + Self { css_prop, jsx } + } + + /// Whether `attribute` of the element `name` is a `css` prop it takes; + /// `devup` tells a Devup UI component + pub(crate) fn attribute( + &self, + name: &JSXElementName<'_>, + attribute: &JSXAttributeItem<'_>, + devup: impl Fn(&str) -> bool, + ) -> bool { + matches!(attribute, JSXAttributeItem::Attribute(attribute) + if attribute.name.as_identifier().is_some_and(|attribute| attribute.name == "css")) + && self.css_prop.takes(name, devup) + } + + /// Where among the props a `jsx()` call gives is the `css` prop the + /// element takes + pub(crate) fn property( + &self, + call: &CallExpression<'_>, + devup: impl Fn(&str) -> bool, + ) -> Option { + let Expression::Identifier(callee) = &call.callee else { + return None; + }; + let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() else { + return None; + }; + (self.jsx.contains(callee.name.as_str()) + && self.css_prop.takes_type(element.as_expression()?, devup)) + .then(|| { + props.properties.iter().rposition(|property| { + matches!(property, ObjectPropertyKind::ObjectProperty(property) + if !property.computed && property.key.static_name().is_some_and(|key| key == "css")) + }) + }) + .flatten() + } +} + +/// Whether `code` may give an element a `css` prop: `css` written as a JSX +/// attribute or as the key of an object +pub(crate) fn may_take_css_prop(code: &str) -> bool { + code.match_indices("css").any(|(index, _)| { + let before = code[..index].chars().next_back(); + let after = code[index + 3..].trim_start(); + let attribute = before.is_some_and(char::is_whitespace) + && after.starts_with('=') + && !after[1..].starts_with('='); + let key = before.is_some_and(|c| c.is_whitespace() || matches!(c, '{' | ',' | '"' | '\'')) + && after + .strip_prefix(['"', '\'']) + .unwrap_or(after) + .trim_start() + .starts_with(':'); + attribute || key + }) +} + +/// Whether importing `source` shows a file uses Emotion +pub(crate) fn is_emotion(source: &str) -> bool { + [EMOTION_REACT, "@emotion/styled"].iter().any(|package| { + source + .strip_prefix(package) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) + }) +} + +/// Where the JSX pragma in the comment text `comment` names `@emotion/react` +/// as the module JSX is built with +#[cfg(test)] +fn emotion_pragma(comment: &str) -> Option { + let closing = if comment.ends_with('*') { "/" } else { "*/" }; + let source = format!("/*{comment}{closing}\nexport const anchor=1;"); + let allocator = oxc_allocator::Allocator::default(); + let parsed = oxc_parser::Parser::new(&allocator, &source, oxc_span::SourceType::tsx()).parse(); + assert!(!parsed.diagnostics.has_errors()); + crate::jsx_import_source::emotion_replacements(&parsed.program) + .first() + .map(|(start, _, _)| start - "/*".len()) +} + +/// The React module an Emotion JSX runtime stands in for +pub(crate) fn react_runtime(source: &str) -> Option<&'static str> { + match source { + "@emotion/react/jsx-runtime" => Some("react/jsx-runtime"), + "@emotion/react/jsx-dev-runtime" => Some("react/jsx-dev-runtime"), + _ => None, + } +} + +/// Whether `imported` from `source` builds an element from a type and props; +/// `compat` is the entry absorbing Emotion's own `jsx` +pub(crate) fn is_jsx_function(source: &str, imported: &str, compat: &str) -> bool { + match source { + "react/jsx-runtime" + | "react/jsx-dev-runtime" + | "@emotion/react/jsx-runtime" + | "@emotion/react/jsx-dev-runtime" => matches!(imported, "jsx" | "jsxs" | "jsxDEV"), + source => { + (source == EMOTION_REACT || source == compat) + && matches!(imported, "jsx" | "createElement") + } + } +} + +/// What a function of the theme requires to give rules at build time +pub(crate) const THEME_FUNCTION: &str = + "a function of the theme must give its rules at once, as `theme => ({ ... })`"; + +/// What a function of the theme requires of its reads of the theme +pub(crate) const THEME_READ: &str = "it may read the theme only as `theme.a.b` in a value, which becomes the CSS variable `var(--a-b)` the `ThemeProvider` sets"; + +/// What CSS text requires of an interpolation it cannot place +pub(crate) const UNPLACED: &str = + "an interpolation in CSS text must be a value, or a mixin standing where a declaration would"; + +/// What CSS text requires of a mixin +pub(crate) const NESTED_MIXIN: &str = + "a mixin must stand outside nested rules, where the parts it composes with can be split"; + +/// `function`, a function of the theme, as the rules it gives, each read of +/// the theme written as the CSS variable the `ThemeProvider` sets for it. +/// `Err` holds the code the build cannot write so, with what it requires. +pub(crate) fn theme_rules<'a>( + ast: &AstBuilder<'a>, + function: &Expression<'a>, +) -> Result, (Expression<'a>, &'static str)> { + let unsupported = || (function.clone_in(ast.allocator()), THEME_FUNCTION); + let (params, body) = match function { + Expression::ArrowFunctionExpression(arrow) if !arrow.r#async => { + let body = match &arrow.body { + ArrowFunctionBody::FunctionBody(body) => returned(body), + body => body.as_expression(), + }; + (&arrow.params, body) + } + Expression::FunctionExpression(function) if !function.r#async && !function.generator => ( + &function.params, + function.body.as_deref().and_then(returned), + ), + _ => return Err(unsupported()), + }; + let (Some(roots), Some(body)) = (theme_roots(params), body) else { + return Err(unsupported()); + }; + let mut rules = body.clone_in_with_semantic_ids(ast.allocator()); + let mut reads = ThemeReads { + ast, + roots: &roots, + unread: None, + }; + reads.visit(&mut rules, false); + match reads.unread { + Some(unread) => Err((unread, THEME_READ)), + None => Ok(rules), + } +} + +/// What a function body gives when it is a single `return` +pub(crate) fn returned<'b, 'a>(body: &'b FunctionBody<'a>) -> Option<&'b Expression<'a>> { + match body.statements.as_slice() { + [Statement::ReturnStatement(statement)] => statement.argument.as_ref(), + _ => None, + } +} + +/// The names a function of the theme reads it by, each with the key of the +/// theme it holds when destructured; `None` for parameters the build cannot +/// follow +fn theme_roots<'a>(params: &FormalParameters<'a>) -> Option)>> { + if params.rest.is_some() { + return None; + } + match params.items.as_slice() { + [] => Some(Vec::new()), + [param] if param.initializer.is_none() => match ¶m.pattern { + BindingPattern::BindingIdentifier(identifier) => { + Some(vec![(identifier.name.as_str(), None)]) + } + BindingPattern::ObjectPattern(object) if object.rest.is_none() => object + .properties + .iter() + .map( + |property| match (&property.value, property.key.static_name()) { + (BindingPattern::BindingIdentifier(local), Some(key)) + if !property.computed => + { + Some((local.name.as_str(), Some(key.to_string()))) + } + _ => None, + }, + ) + .collect(), + _ => None, + }, + _ => None, + } +} + +/// Writes each read of the theme in a value as the CSS variable it becomes, +/// keeping the first read that is not one +struct ThemeReads<'r, 'a> { + ast: &'r AstBuilder<'a>, + roots: &'r [(&'a str, Option)], + unread: Option>, +} + +/// How an expression reads the theme +enum ThemeRead { + /// By the path of keys to a value + Path(Vec), + /// Otherwise, as a whole or through a call or a key only the runtime gives + Other, +} + +impl<'a> ThemeReads<'_, 'a> { + /// How `expression` reads the theme, when it does + fn path(&self, expression: &Expression<'a>) -> Option { + let mut path = Vec::new(); + let mut exact = true; + let mut cursor = expression; + loop { + match cursor { + Expression::StaticMemberExpression(member) => { + path.push(member.property.name.to_string()); + cursor = &member.object; + } + Expression::ComputedMemberExpression(member) => { + match get_key_by_literal_expression(&member.expression) { + Some(key) => path.push(key.into_owned()), + None => exact = false, + } + cursor = &member.object; + } + Expression::CallExpression(call) => { + exact = false; + cursor = &call.callee; + } + Expression::ParenthesizedExpression(inner) => cursor = &inner.expression, + Expression::Identifier(identifier) => { + let (_, key) = self + .roots + .iter() + .find(|(local, _)| *local == identifier.name.as_str())?; + path.extend(key.clone()); + path.reverse(); + return Some(if exact && !path.is_empty() { + ThemeRead::Path(path) + } else { + ThemeRead::Other + }); + } + _ => return None, + } + } + } + + /// Visit `expression`, which stands where a CSS value does when `value` + fn visit(&mut self, expression: &mut Expression<'a>, value: bool) { + match self.path(expression) { + Some(ThemeRead::Path(path)) if value => { + let variable = format!("var(--{})", path.join("-")); + *expression = Expression::new_string_literal( + SPAN, + Str::from_in(variable.as_str(), self.ast.allocator()), + None, + self.ast, + ); + return; + } + Some(_) => { + if self.unread.is_none() { + self.unread = Some(expression.clone_in(self.ast.allocator())); + } + return; + } + None => {} + } + match expression { + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + match property { + ObjectPropertyKind::ObjectProperty(property) => { + if let Some(key) = property.key.as_expression_mut() { + self.visit(key, false); + } + self.visit(&mut property.value, true); + } + ObjectPropertyKind::SpreadProperty(spread) => { + self.visit(&mut spread.argument, false); + } + } + } + } + Expression::TemplateLiteral(template) => { + for expression in &mut template.expressions { + self.visit(expression, true); + } + } + Expression::ArrayExpression(array) => { + for element in &mut array.elements { + if let Some(element) = element.as_expression_mut() { + self.visit(element, value); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.visit(&mut conditional.test, false); + self.visit(&mut conditional.consequent, value); + self.visit(&mut conditional.alternate, value); + } + Expression::LogicalExpression(logical) => { + self.visit(&mut logical.left, false); + self.visit(&mut logical.right, value); + } + // Text joined to a string stays text + Expression::BinaryExpression(binary) + if binary.operator == BinaryOperator::Addition + && [&binary.left, &binary.right].into_iter().any(|side| { + matches!( + side, + Expression::StringLiteral(_) | Expression::TemplateLiteral(_) + ) + }) => + { + self.visit(&mut binary.left, value); + self.visit(&mut binary.right, value); + } + Expression::ParenthesizedExpression(inner) => self.visit(&mut inner.expression, value), + expression => walk_mut::walk_expression(self, expression), + } + } +} + +impl<'a> VisitMut<'a> for ThemeReads<'_, 'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + self.visit(expression, false); + } +} + +/// CSS text as the parts of a `css` prop: when `mixins`, an interpolation +/// standing where a declaration would is a mixin composed there, splitting the +/// text around it. `Err` holds an interpolation the parts cannot place, with +/// what the text requires of it. +pub(crate) fn template_parts<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + mixins: bool, +) -> Result>, (Expression<'a>, &'static str)> { + let mut parts = Vec::new(); + let mut text = String::new(); + let mut depth = 0usize; + let mut from = 0; + for (index, expression) in template.expressions.iter().enumerate() { + let quasi = template.quasis[index].value.raw.as_str(); + text.push_str(quasi); + depth = (depth + quasi.matches('{').count()).saturating_sub(quasi.matches('}').count()); + let place = interpolation_place(&text, &template.quasis[index + 1..]); + if matches!(place, Place::Value) || get_string_by_literal_expression(expression).is_some() { + continue; + } + let requirement = match place { + Place::Statement if mixins && depth == 0 => { + parts.extend(segment(ast, template, from, index)); + parts.push(expression.clone_in_with_semantic_ids(ast.allocator())); + from = index + 1; + continue; + } + Place::Statement if mixins => NESTED_MIXIN, + _ => UNPLACED, + }; + return Err((expression.clone_in(ast.allocator()), requirement)); + } + parts.extend(segment(ast, template, from, template.expressions.len())); + Ok(parts) +} + +/// The text of `template` from the quasi `from` to the quasi `to`, with the +/// values between them; `None` when it holds nothing +fn segment<'a>( + ast: &AstBuilder<'a>, + template: &TemplateLiteral<'a>, + from: usize, + to: usize, +) -> Option> { + let quasis = &template.quasis[from..=to]; + if from == to && quasis[0].value.raw.trim().is_empty() { + return None; + } + let allocator = ast.allocator(); + let quasis = quasis.iter().enumerate().map(|(index, quasi)| { + TemplateElement::new( + SPAN, + TemplateElementValue { + raw: quasi.value.raw, + cooked: quasi.value.cooked, + }, + index == to - from, + ast, + ) + }); + let expressions = template.expressions[from..to] + .iter() + .map(|expression| expression.clone_in_with_semantic_ids(allocator)); + Some(Expression::new_template_literal( + SPAN, + oxc_allocator::Vec::from_iter_in(quasis, ast), + oxc_allocator::Vec::from_iter_in(expressions, ast), + ast, + )) +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::literal_string_with_formatting_args +)] +mod tests { + use super::*; + use crate::utils::expression_to_code; + use oxc_allocator::Allocator; + use oxc_parser::Parser; + use oxc_span::SourceType; + + fn parsed<'a>(allocator: &'a Allocator, code: &'a str) -> Expression<'a> { + Parser::new(allocator, code, SourceType::tsx()) + .parse_expression() + .unwrap() + } + + #[test] + fn test_css_prop_needles() { + for code in [ + "
", + "", + "jsx('div', { css: a })", + "jsx('div', {css:a})", + "jsx('div', {\"css\": a})", + "jsx('div', {'css' : a})", + "f(a,css:b)", + ] { + assert!(may_take_css_prop(code), "{code}"); + } + for code in [ + "import './a.css'", + "a.css = b", + "if (a css == b) {}", + "css(a)", + "
", + "css", + ] { + assert!(!may_take_css_prop(code), "{code}"); + } + } + + #[test] + fn test_css_prop_of_aliases() { + let mut aliases = HashMap::new(); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + assert_eq!(CssProp::of(&aliases, "
", true), CssProp::Off); + assert_eq!( + CssProp::of(&aliases, "
", false), + CssProp::Elements + ); + assert_eq!( + CssProp::of(&aliases, "
", true), + CssProp::Everywhere + ); + } + + #[test] + fn test_emotion_sources_and_pragmas() { + assert!(is_emotion("@emotion/react")); + assert!(is_emotion("@emotion/react/jsx-runtime")); + assert!(is_emotion("@emotion/styled")); + assert!(!is_emotion("@emotion/reactive")); + assert!(!is_emotion("@emotion/css")); + + assert_eq!( + emotion_pragma("* @jsxImportSource @emotion/react "), + Some(19) + ); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/react*"), Some(17)); + assert_eq!( + emotion_pragma(" @jsxImportSource\t@emotion/react"), + Some(18) + ); + assert_eq!(emotion_pragma("@jsxImportSource react"), None); + assert_eq!(emotion_pragma("@jsxImportSource @emotion/reactive"), None); + assert_eq!(emotion_pragma("@jsx jsx"), None); + + assert_eq!( + react_runtime("@emotion/react/jsx-runtime"), + Some("react/jsx-runtime") + ); + assert_eq!( + react_runtime("@emotion/react/jsx-dev-runtime"), + Some("react/jsx-dev-runtime") + ); + assert_eq!(react_runtime("@emotion/react"), None); + + let compat = "@devup-ui/react/compat"; + assert!(is_jsx_function("react/jsx-runtime", "jsxs", compat)); + assert!(is_jsx_function( + "@emotion/react/jsx-dev-runtime", + "jsxDEV", + compat + )); + assert!(!is_jsx_function("react/jsx-runtime", "Fragment", compat)); + assert!(is_jsx_function("@emotion/react", "createElement", compat)); + assert!(is_jsx_function(compat, "jsx", compat)); + assert!(!is_jsx_function("react", "createElement", compat)); + assert!(!is_jsx_function(EMOTION_REACT, "css", compat)); + } + + #[test] + fn test_takes() { + let allocator = Allocator::default(); + let devup = |name: &str| name == "Box"; + for (code, off, elements, everywhere) in [ + ("
", false, true, true), + ("", false, true, true), + ("", false, true, true), + ("", false, false, true), + ("", false, false, true), + ] { + let Expression::JSXElement(element) = parsed(&allocator, code) else { + panic!("{code}"); + }; + let name = &element.opening_element.name; + assert_eq!(CssProp::Off.takes(name, devup), off, "{code}"); + assert_eq!(CssProp::Elements.takes(name, devup), elements, "{code}"); + assert_eq!(CssProp::Everywhere.takes(name, devup), everywhere, "{code}"); + } + for (code, elements) in [ + ("'div'", true), + ("Box", true), + ("Custom", false), + ("f()", false), + ] { + let element = parsed(&allocator, code); + assert!(!CssProp::Off.takes_type(&element, devup), "{code}"); + assert_eq!( + CssProp::Elements.takes_type(&element, devup), + elements, + "{code}" + ); + assert!(CssProp::Everywhere.takes_type(&element, devup), "{code}"); + } + } + + #[test] + fn test_theme_rules() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + for (code, expected) in [ + ( + "theme => ({ color: theme.colors.primary })", + "({color:`var(--colors-primary)`});", + ), + ( + "({ colors, space: s }) => ({ color: colors.text, margin: s[2] })", + "({color:`var(--colors-text)`,margin:`var(--space-2)`});", + ), + ( + "function (t) { return [{ border: `1px solid ${t.line}` }, a && { color: '#' + t.a }]; }", + "[{border:`1px solid ${`var(--line)`}`},a&&{color:`#`+`var(--a)`}];", + ), + ( + "t => c ? { color: (t.a) } : { [k]: t['b'], ...rest }", + "c?{color:`var(--a)`}:{[k]:`var(--b)`,...rest};", + ), + ("() => ({ color: 'red' })", "({color:`red`});"), + ] { + let rules = theme_rules(&ast, &parsed(&allocator, code)).unwrap(); + assert_eq!(expression_to_code(&rules), expected, "{code}"); + } + for (code, unread) in [ + ("t => ({ color: t })", "t"), + ("t => ({ ...t.mixins })", "t.mixins"), + ("t => ({ margin: t.spacing(2) })", "t.spacing(2)"), + ("t => ({ color: t.colors[name] })", "t.colors[name]"), + ("t => t.dark ? {} : {}", "t.dark"), + ("t => ({ width: t.a * 2 })", "t.a"), + ("t => ({ color: t.a || 'red' })", "t.a"), + ("t => ({ color: f(t.a), border: t.b() })", "t.a"), + ] { + let (read, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_READ, "{code}"); + assert_eq!(expression_to_code(&read), format!("{unread};"), "{code}"); + } + for code in [ + "async t => ({})", + "function* (t) { return {}; }", + "async function (t) { return {}; }", + "(t, u) => ({})", + "(...t) => ({})", + "(t = {}) => ({})", + "([t]) => ({})", + "({ a: { b } }) => ({})", + "({ ...t }) => ({})", + "({ [k]: t }) => ({})", + "t => { const a = 1; return {}; }", + "t => {}", + "function (t) {}", + "a", + ] { + let (_, requirement) = theme_rules(&ast, &parsed(&allocator, code)).unwrap_err(); + assert_eq!(requirement, THEME_FUNCTION, "{code}"); + } + } + + #[test] + fn test_template_parts() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parts = |code: &'static str, mixins: bool| { + let Expression::TemplateLiteral(template) = parsed(&allocator, code) else { + panic!("{code}"); + }; + template_parts(&ast, &template, mixins) + .map(|parts| parts.iter().map(expression_to_code).collect::()) + }; + assert_eq!( + parts("`color: ${c}; ${'margin: 0'};`", false).unwrap(), + "`color: ${c}; ${`margin: 0`};`;" + ); + assert_eq!( + parts("`${base}; color: ${c}; ${other}`", true).unwrap(), + "base;`; color: ${c}; `;other;" + ); + assert_eq!(parts("``", true).unwrap(), ""); + let (_, requirement) = parts("`${base}; color: red;`", false).unwrap_err(); + assert_eq!(requirement, UNPLACED); + let (_, requirement) = parts("`&:hover { ${base}; }`", true).unwrap_err(); + assert_eq!(requirement, NESTED_MIXIN); + let (code, requirement) = parts("`${selector} { color: red; }`", true).unwrap_err(); + assert_eq!(requirement, UNPLACED); + assert_eq!(expression_to_code(&code), "selector;"); + } +} diff --git a/libs/extractor/src/css_prop_contract_tests.rs b/libs/extractor/src/css_prop_contract_tests.rs new file mode 100644 index 000000000..14a575fdf --- /dev/null +++ b/libs/extractor/src/css_prop_contract_tests.rs @@ -0,0 +1,283 @@ +use crate::{ExtractOption, ExtractStyleValue, ImportAlias, extract}; +use css::{class_map::reset_class_map, file_map::reset_file_map}; +use rstest::rstest; +use serial_test::serial; +use std::collections::HashMap; + +#[test] +fn expression_layers_keep_authored_first_use_before_styles_are_inserted() { + use crate::{ + ExtractStyleProp, + extract_style::{ + extract_dynamic_style::ExtractDynamicStyle, extract_layer_order::ExtractLayerOrder, + extract_static_style::ExtractStaticStyle, + }, + style_collector::StyleCollector, + }; + use oxc_allocator::Allocator; + use oxc_ast::{ast::Expression, builder::AstBuilder}; + use oxc_span::SPAN; + + // Given: an expression payload repeats a nested layer around a dynamic layer. + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let first = ExtractStyleValue::Static(ExtractStaticStyle::new_with_layer( + "color", + "red", + 0, + None, + Some("zz.inner".to_string()), + )); + let mut dynamic = ExtractDynamicStyle::new("color", 0, "--tone", None); + dynamic.layer = Some("aa".to_string()); + let props = [ + ExtractStyleProp::Expression { + expression: Expression::new_identifier(SPAN, "classes", &ast), + styles: vec![first.clone(), ExtractStyleValue::Dynamic(dynamic), first], + }, + ExtractStyleProp::Static(ExtractStyleValue::Static( + ExtractStaticStyle::new_with_layer("color", "blue", 0, None, Some("bb".to_string())), + )), + ]; + let mut collector = StyleCollector::default(); + + // When: authored layer names are registered before composition inserts atoms. + collector.use_layers(&props); + + // Then: expression payloads contribute first-use metadata, not emitted atoms. + assert_eq!( + collector.into_styles("test.tsx"), + std::iter::once(ExtractStyleValue::LayerOrder(ExtractLayerOrder { + file: "test.tsx".to_string(), + layers: vec!["zz".into(), "zz.inner".into(), "aa".into(), "bb".into()], + })) + .collect() + ); +} + +fn options() -> ExtractOption { + ExtractOption { + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]), + ..ExtractOption::default() + } +} + +#[test] +#[serial] +fn empty_css_expression_is_rejected_when_mixed_with_valid_forms() { + // Given: the original mixed fixture, including its invalid final attribute. + reset_class_map(); + reset_file_map(); + let source = "export const App = ({ c }) => <>
;"; + + // When: the public extractor applies main's strict JSX parsing contract. + let error = extract("test.tsx", source, options()) + .err() + .unwrap_or_else(|| panic!("invalid JSX was accepted")) + .to_string(); + + // Then: the original invalid input has the exact located parser error. + assert_eq!( + error, + "test.tsx:1:192: JSX attributes must only be assigned a non-empty 'expression'." + ); +} + +#[rstest] +#[case::repeated( + "[{'@layer':{zz:{color:'red'}}},{'@layer':{aa:{color:'blue'}}},{'@layer':{zz:{color:'green'}}}]", + vec!["zz", "aa"] +)] +#[case::conditional( + "[flag && {'@layer':{zz:{color:'red'}}},{'@layer':{aa:{color:'blue'}}},{'@layer':{zz:{color:'green'}}}]", + vec!["zz", "aa"] +)] +#[case::nested( + "[{'@layer':{'zz.inner':{color:'red'}}},{'@layer':{aa:{color:'blue'}}},{'@layer':{'zz.inner':{color:'green'}}}]", + vec!["zz", "zz.inner", "aa"] +)] +#[serial] +fn repeated_layers_keep_first_use_when_later_atoms_replace_earlier_ones( + #[case] rules: &str, + #[case] expected: Vec<&str>, +) { + // Given: the first layer's first atom will lose D1 composition. + reset_class_map(); + reset_file_map(); + let source = format!("export const view=
;"); + + // When: the public extractor composes all authored parts. + let output = extract("test.tsx", &source, options()).unwrap_or_else(|error| panic!("{error}")); + + // Then: only the two winning atoms are emitted, without losing first-use metadata. + let mut colors: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + colors.sort_unstable(); + assert_eq!(colors, vec![("color", "blue"), ("color", "green")]); + let layers: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::LayerOrder(order) => Some(order.layers.clone()), + _ => None, + }) + .collect(); + assert_eq!(layers, vec![expected]); +} + +#[rstest] +#[case::embedded("/* not@jsxImportSource @emotion/react */\n", "", false)] +#[case::joined("/* @jsxImportSource@emotion/react */\n", "", false)] +#[case::suffix("/* @jsxImportSource @emotion/reactive */\n", "", false)] +#[case::post_header( + "export const anchor=1;\n", + "/* @jsxImportSource @emotion/react */\n", + false +)] +#[case::header("/** @jsxImportSource @emotion/react */\n", "", true)] +#[case::directive("'use client';\n/*@jsxImportSource\t@emotion/react*/\n", "", true)] +#[case::import("import { jsx } from '@emotion/react';\n", "", true)] +#[case::runtime("import { jsx } from '@emotion/react/jsx-runtime';\n", "", true)] +#[serial] +fn custom_css_is_consumed_only_when_emotion_is_enabled( + #[case] header: &str, + #[case] later: &str, + #[case] enabled: bool, +) { + // Given: a custom component owns css unless an actual Emotion signal opts it in. + reset_class_map(); + reset_file_map(); + let source = format!("{header}{later}export const view=;"); + + // When: the public extractor sees the file with the Emotion alias enabled. + let output = extract("test.tsx", &source, options()).unwrap_or_else(|error| panic!("{error}")); + + // Then: only a valid header or import transfers ownership of the prop. + assert_eq!(output.styles.len(), usize::from(enabled), "{}", output.code); + assert_eq!(output.code.contains("css="), !enabled, "{}", output.code); +} + +#[test] +#[serial] +fn layers_follow_authored_order_when_css_array_classes_are_generated() { + // Given: layers whose first-use order differs from alphabetical order, plus a D1 override. + reset_class_map(); + reset_file_map(); + let source = "export const view=
;"; + + // When: public extraction composes the css array and generates its classes. + let output = extract("test.tsx", source, options()).unwrap_or_else(|error| panic!("{error}")); + + // Then: cascade layers retain first-use order while the later unlayered color wins. + let layers: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::LayerOrder(order) => Some(order.layers.clone()), + _ => None, + }) + .collect(); + assert_eq!(layers, vec![vec!["zz".to_string(), "aa".to_string()]]); + let plain: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) if style.layer.is_none() => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!(plain, vec![("color", "green")]); + assert!(output.code.contains("className="), "{}", output.code); +} + +#[rstest] +#[case::leading_zero("t.colors['01']", "var(--colors-01)")] +#[case::numeric_string("t.colors['1.00']", "var(--colors-1.00)")] +#[case::numeric("t.colors[1]", "var(--colors-1)")] +#[case::static_key("t.colors.primary", "var(--colors-primary)")] +#[case::template("t.colors[`01`]", "var(--colors-01)")] +#[serial] +fn theme_keys_preserve_exact_text_when_computed(#[case] read: &str, #[case] expected: &str) { + // Given: a theme key whose spelling is part of the provider's CSS variable name. + reset_class_map(); + reset_file_map(); + let source = format!("export const view=
({{color:{read}}})}} />;"); + + // When: public extraction lowers the function's theme read. + let output = extract("test.tsx", &source, options()).unwrap_or_else(|error| panic!("{error}")); + + // Then: the generated declaration reads the exact corresponding variable. + let values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + assert_eq!(values, vec![("color", expected)]); +} + +#[rstest] +#[case::inline( + "", + "[{margin:2,marginTop:0,opacity:0.5,lineHeight:1.5,'&:hover':{'padding':3}}]" +)] +#[case::named( + "const rules=[{margin:2,marginTop:0,opacity:0.5,lineHeight:1.5,'&:hover':{'padding':3}}];", + "rules" +)] +#[case::nested( + "const rules=[[{margin:2,marginTop:0,opacity:0.5,lineHeight:1.5,'&:hover':{'padding':3}}]];", + "rules" +)] +#[serial] +fn emotion_numbers_stay_pixels_when_rule_arrays_are_inlined( + #[case] declaration: &str, + #[case] rules: &str, +) { + // Given: equivalent inline and constant arrays, including nested rules and unitless values. + reset_class_map(); + reset_file_map(); + let source = format!("{declaration}export const view=
;"); + + // When: public extraction inlines constants and lowers the css prop. + let output = extract("test.tsx", &source, options()).unwrap_or_else(|error| panic!("{error}")); + + // Then: Emotion lengths use px without Devup scaling; zero and unitless values stay bare. + let mut values: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property.as_str(), style.value.as_str())) + } + _ => None, + }) + .collect(); + values.sort_unstable(); + assert_eq!( + values, + vec![ + ("line-height", "1.5"), + ("margin", "2px"), + ("margin-top", "0"), + ("opacity", ".5"), + ("padding", "3px") + ] + ); +} diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index 35630a4ec..6ec1ec89e 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -227,14 +227,14 @@ pub struct TemplateStyles { pub unplaced: Vec, } -enum Place { +pub(crate) enum Place { Value, Statement, Other, } /// Where an interpolation stands, from the CSS written before and after it -fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { +pub(crate) fn interpolation_place(before: &str, after: &[TemplateElement<'_>]) -> Place { let head = &before[before.rfind([';', '{', '}']).map_or(0, |index| index + 1)..]; let rest: String = after.iter().map(|quasi| quasi.value.raw.as_str()).collect(); let end = rest.find([';', '{', '}']); diff --git a/libs/extractor/src/dead_properties/origin.rs b/libs/extractor/src/dead_properties/origin.rs index 5da5637a4..a7b806341 100644 --- a/libs/extractor/src/dead_properties/origin.rs +++ b/libs/extractor/src/dead_properties/origin.rs @@ -1,6 +1,6 @@ use crate::{ ExtractOption, - import_alias_visit::{source_offset, transform_import_aliases_with_edits}, + import_alias_visit::{Aliased, source_offset, transform_import_aliases_with_edits}, }; pub(crate) fn map_evaluated( @@ -33,7 +33,11 @@ pub(crate) fn evaluated_calls( filename: &str, option: &ExtractOption, ) -> Vec<(usize, usize)> { - let (aliased, edits) = transform_import_aliases_with_edits( + let Aliased { + code: aliased, + edits, + .. + } = transform_import_aliases_with_edits( source, filename, &option.package, diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 1140d8575..547a551db 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -1,3 +1,4 @@ +use super::styled_css_slot::{StyledContext, StyledCssSlot}; use super::styled_jsx::StyledJsx; use rustc_hash::FxHashMap; @@ -67,6 +68,7 @@ pub struct StyledDefinition<'a> { /// Its `shouldForwardProp`, as the build evaluates it forward: Option, bindings: Vec<(String, Expression<'a>)>, + css_slot: Option, } /// Whether a styled component renders a tag, which takes only valid @@ -144,6 +146,7 @@ impl<'a> StyledDefinition<'a> { attrs: clone_all(builder, &self.attrs).collect(), reads: self.reads.clone(), forward: self.forward.clone(), + css_slot: self.css_slot.clone(), bindings: self .bindings .iter() @@ -226,6 +229,51 @@ impl StyledDefinition<'_> { } } +impl<'a> StyledDefinition<'a> { + /// The tag an element using this component can render in its place, with + /// the styles the component gives it: a tag, with no attrs, no props read + /// and no value only the runtime gives + #[must_use] + pub fn inline(&self) -> Option<(&str, &[ExtractStyleProp<'a>])> { + (self.fixed_native() && self.bindings.is_empty()) + .then_some((self.name.as_str(), self.styles.as_slice())) + } + + fn fixed_native(&self) -> bool { + renders_tag(&self.name, self.bound.as_ref()) + && self.attrs.is_empty() + && self.reads == Reads::default() + && self.classes.is_empty() + && self.styles.iter().all(fixed) + } + + fn with_css_slot(mut self, slot: Option<&StyledCssSlot>) -> Self { + self.css_slot = slot + .filter(|_| self.fixed_native() && !self.bindings.is_empty()) + .cloned(); + self + } + + pub(crate) const fn css_slot(&self) -> Option<&StyledCssSlot> { + self.css_slot.as_ref() + } + + /// The styles the component gives what it renders + #[must_use] + pub fn styles(&self) -> &[ExtractStyleProp<'a>] { + &self.styles + } +} + +/// Whether `prop` holds only values the build knows +fn fixed(prop: &ExtractStyleProp<'_>) -> bool { + match prop { + ExtractStyleProp::Static(value) => !matches!(value, ExtractStyleValue::Dynamic(_)), + ExtractStyleProp::StaticArray(props) => props.iter().all(fixed), + _ => false, + } +} + /// `Component.withComponent(target)`: the styles and attrs of `definition` /// rendering `target`, a tag name or a component JSX can name; `None` for any /// other target @@ -234,7 +282,7 @@ pub fn with_component<'a>( definition: &StyledDefinition<'a>, target: &Expression<'a>, split_filename: Option<&str>, - helper: &mut Option, + context: StyledContext<'_>, ) -> Option<(Expression<'a>, StyledDefinition<'a>)> { let name = match unwrap_syntax_only(target) { Expression::StringLiteral(literal) => literal.value.to_string(), @@ -270,6 +318,16 @@ pub fn with_component<'a>( renders_tag(&base.name, None), definition.forward.as_ref(), ); + let new_definition = base + .definition( + ast_builder, + classes, + &styles, + &definition.attrs, + (definition.reads.clone(), definition.forward.clone()), + &definition.bindings, + ) + .with_css_slot(context.css_slot); let component = create_styled_component( ast_builder, &base, @@ -282,16 +340,9 @@ pub fn with_component<'a>( .map(|(name, value)| (name.clone(), value.clone_in(allocator))) .collect(), withheld: &withheld, + css_slot: new_definition.css_slot(), }, - helper, - ); - let new_definition = base.definition( - ast_builder, - classes, - &styles, - &definition.attrs, - (definition.reads.clone(), definition.forward.clone()), - &definition.bindings, + context.jsx, ); Some(( base.render( @@ -414,6 +465,7 @@ impl<'a> Base<'a> { attrs: attrs.iter().map(|attr| attr.clone_in(allocator)).collect(), reads, forward, + css_slot: None, bindings: bindings .iter() .map(|(name, value)| (name.clone(), value.clone_in(allocator))) @@ -592,7 +644,7 @@ pub fn extract_style_from_styled<'a>( imports: &FxHashMap, modifiers: (&[Expression<'a>], Option), inherited: Option<&StyledDefinition<'a>>, - helper: &mut Option, + context: StyledContext<'_>, ) -> StyledExtraction<'a> { let (attrs, forward) = modifiers; let mut original_scope = expression.clone_in(ast_builder.allocator()); @@ -730,14 +782,16 @@ pub fn extract_style_from_styled<'a>( .collect() }); let withheld = base.withheld(&reads, forward.as_ref()); - let definition = base.definition( - ast_builder, - classes, - &props_styles, - &attrs, - (reads, forward), - &bindings, - ); + let definition = base + .definition( + ast_builder, + classes, + &props_styles, + &attrs, + (reads, forward), + &bindings, + ) + .with_css_slot(context.css_slot); let component = create_styled_component( ast_builder, &base, @@ -746,8 +800,9 @@ pub fn extract_style_from_styled<'a>( style_vars: &gen_styles(ast_builder, &props_styles, None), bindings, withheld: &withheld, + css_slot: definition.css_slot(), }, - helper, + context.jsx, ); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); @@ -840,14 +895,16 @@ pub fn extract_style_from_styled<'a>( )), ); let withheld = base.withheld(&reads, forward.as_ref()); - let definition = base.definition( - ast_builder, - classes, - &styles, - &attrs, - (reads, forward), - &bindings, - ); + let definition = base + .definition( + ast_builder, + classes, + &styles, + &attrs, + (reads, forward), + &bindings, + ) + .with_css_slot(context.css_slot); let component = create_styled_component( ast_builder, &base, @@ -856,8 +913,9 @@ pub fn extract_style_from_styled<'a>( style_vars: &gen_styles(ast_builder, &styles, None), bindings, withheld: &withheld, + css_slot: definition.css_slot(), }, - helper, + context.jsx, ); let styled_component = base.render(ast_builder, apply_attrs(ast_builder, component, &attrs)); @@ -1430,6 +1488,7 @@ struct StyledRender<'b, 'a> { style_vars: &'b Option>, bindings: Vec<(String, Expression<'a>)>, withheld: &'b [String], + css_slot: Option<&'b StyledCssSlot>, } fn create_styled_component<'a>( @@ -1443,6 +1502,7 @@ fn create_styled_component<'a>( style_vars, bindings, withheld, + css_slot, } = render; let params = FormalParameters::boxed( SPAN, @@ -1541,7 +1601,8 @@ fn create_styled_component<'a>( None::>>, ast_builder, ); - let class_name = class_name.as_ref().map_or_else( + let replacement = css_slot.map(|slot| slot.classes(ast_builder, class_name.as_ref())); + let class_name = replacement.as_ref().or(class_name.as_ref()).map_or_else( || Expression::new_identifier(SPAN, "className", ast_builder), |name| { wrap_array_filter( @@ -1567,6 +1628,12 @@ fn create_styled_component<'a>( .unwrap_or_else(|| style_vars.clone_in(ast_builder.allocator())) }, ); + let omissions = css_slot.map(|slot| { + let mut omissions = withheld.to_vec(); + omissions.push(slot.name().to_string()); + omissions + }); + let withheld = omissions.as_deref().unwrap_or(withheld); let body = if let Some(helper) = helper { helper.render( ast_builder, diff --git a/libs/extractor/src/extractor/mod.rs b/libs/extractor/src/extractor/mod.rs index 09b9c1b27..594d7c0fc 100644 --- a/libs/extractor/src/extractor/mod.rs +++ b/libs/extractor/src/extractor/mod.rs @@ -9,6 +9,7 @@ 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 styled_css_slot; pub(super) mod styled_jsx; mod styled_record; mod styled_record_evaluation; diff --git a/libs/extractor/src/extractor/styled_css_slot.rs b/libs/extractor/src/extractor/styled_css_slot.rs new file mode 100644 index 000000000..b61165f68 --- /dev/null +++ b/libs/extractor/src/extractor/styled_css_slot.rs @@ -0,0 +1,165 @@ +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::{ + ast::{ + BindingIdentifier, ComputedMemberExpression, Expression, IdentifierName, + IdentifierReference, JSXAttributeItem, JSXAttributeName, JSXAttributeValue, JSXIdentifier, + ObjectPropertyKind, Program, PropertyKey, PropertyKind, Str, + }, + builder::AstBuilder, +}; +use oxc_ast_visit::{ + Visit, + walk::{walk_computed_member_expression, walk_property_key}, +}; +use oxc_span::SPAN; +use oxc_syntax::operator::LogicalOperator; +use rustc_hash::FxHashSet; + +use super::styled_jsx::StyledJsx; +use crate::utils::unwrap_syntax_only; + +pub(crate) struct StyledContext<'s> { + pub jsx: &'s mut Option, + pub css_slot: Option<&'s StyledCssSlot>, +} + +pub(crate) enum CssPropLowering { + Retained, + Inline(String), + Slot(StyledCssSlot), +} + +#[derive(Clone)] +pub(crate) struct StyledCssSlot(String); + +#[derive(Default)] +struct Names(FxHashSet); + +fn static_key_name<'a>(expression: &Expression<'a>) -> Option> { + match unwrap_syntax_only(expression) { + Expression::StringLiteral(literal) => Some(literal.value), + Expression::TemplateLiteral(template) => template.single_quasi(), + Expression::TSTypeAssertion(assertion) => static_key_name(&assertion.expression), + _ => None, + } +} + +impl<'a> Visit<'a> for Names { + fn visit_binding_identifier(&mut self, it: &BindingIdentifier<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_identifier_reference(&mut self, it: &IdentifierReference<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_identifier_name(&mut self, it: &IdentifierName<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_jsx_identifier(&mut self, it: &JSXIdentifier<'a>) { + self.0.insert(it.name.to_string()); + } + + fn visit_property_key(&mut self, it: &PropertyKey<'a>) { + if let Some(name) = it + .as_expression() + .and_then(static_key_name) + .map(|name| name.to_string()) + .or_else(|| it.static_name().map(std::borrow::Cow::into_owned)) + { + self.0.insert(name); + } + walk_property_key(self, it); + } + + fn visit_computed_member_expression(&mut self, it: &ComputedMemberExpression<'a>) { + if let Some(name) = static_key_name(&it.expression).or_else(|| it.static_property_name()) { + self.0.insert(name.to_string()); + } + walk_computed_member_expression(self, it); + } +} + +impl StyledCssSlot { + /// Reserve a private prop before extraction can remove authored names or keys. + pub(crate) fn reserve(program: &Program<'_>) -> Self { + let mut names = Names::default(); + names.visit_program(program); + let mut suffix = 0; + loop { + let name = format!("__devupCss{suffix}"); + if !names.0.contains(&name) { + return Self(name); + } + suffix += 1; + } + } + + pub(crate) fn name(&self) -> &str { + &self.0 + } + + pub(crate) fn classes<'a>( + &self, + ast: &AstBuilder<'a>, + own: Option<&Expression<'a>>, + ) -> Expression<'a> { + Expression::new_logical_expression( + SPAN, + Expression::new_static_member_expression( + SPAN, + Expression::new_identifier(SPAN, "rest", ast), + IdentifierName::new(SPAN, Str::from_in(self.name(), ast.allocator()), ast), + false, + ast, + ), + LogicalOperator::Coalesce, + own.map_or_else( + || Expression::new_string_literal(SPAN, "", None, ast), + |own| own.clone_in(ast.allocator()), + ), + ast, + ) + } + + pub(crate) fn attribute<'a>( + &self, + ast: &AstBuilder<'a>, + classes: Option>, + ) -> JSXAttributeItem<'a> { + JSXAttributeItem::new_attribute( + SPAN, + JSXAttributeName::new_identifier(SPAN, Str::from_in(self.name(), ast.allocator()), ast), + Some(JSXAttributeValue::new_expression_container( + SPAN, + classes + .unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)) + .into(), + ast, + )), + ast, + ) + } + + pub(crate) fn property<'a>( + &self, + ast: &AstBuilder<'a>, + classes: Option>, + ) -> ObjectPropertyKind<'a> { + ObjectPropertyKind::new_object_property( + SPAN, + PropertyKind::Init, + PropertyKey::new_static_identifier( + SPAN, + Str::from_in(self.name(), ast.allocator()), + ast, + ), + classes.unwrap_or_else(|| Expression::new_string_literal(SPAN, "", None, ast)), + false, + false, + false, + ast, + ) + } +} diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 5f326b019..12edf0526 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -8,14 +8,17 @@ //! - `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` use crate::ImportAlias; +use crate::css_prop::{ + CssProp, EMOTION_REACT, is_emotion, is_jsx_function, react_runtime, returned, +}; use crate::utils::{ get_str_by_property_key, is_vanilla_extract_file, js_number_literal, keeps_bare_number, }; use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, CallExpression, Expression, ImportDeclarationSpecifier, JSXAttributeItem, - JSXAttributeValue, JSXElementName, JSXOpeningElement, ModuleExportName, ObjectPropertyKind, - Statement, + Argument, ArrowFunctionBody, CallExpression, Expression, ImportDeclarationSpecifier, + JSXAttributeItem, JSXAttributeValue, JSXElementName, JSXOpeningElement, LogicalOperator, + ModuleExportName, ObjectPropertyKind, Statement, }; use oxc_ast_visit::{ Visit, @@ -46,6 +49,12 @@ struct LibraryNumbers<'n> { styled: Vec<&'n str>, /// Local names of components taking rules in `styles` (Emotion's `Global`) components: Vec<&'n str>, + /// Which elements take the `css` prop, whose rules are Emotion's + css_prop: CssProp, + /// Local names of Devup UI's exports, which take the `css` prop as tags do + devup: Vec<&'n str>, + /// Local names of the functions building elements from a type and props + jsx: Vec<&'n str>, replacements: Vec<(usize, usize, String)>, } @@ -97,6 +106,66 @@ impl LibraryNumbers<'_> { } } + /// The rules a `css` prop composes: a function of the theme gives them + fn pixelify_css(&mut self, value: &Expression) { + match value { + Expression::ArrayExpression(array) => { + for element in &array.elements { + if let Some(element) = element.as_expression() { + self.pixelify_css(element); + } + } + } + Expression::ConditionalExpression(conditional) => { + self.pixelify_css(&conditional.consequent); + self.pixelify_css(&conditional.alternate); + } + Expression::LogicalExpression(logical) => { + if logical.operator != LogicalOperator::And { + self.pixelify_css(&logical.left); + } + self.pixelify_css(&logical.right); + } + Expression::ParenthesizedExpression(inner) => self.pixelify_css(&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_css(rules); + } + } + Expression::FunctionExpression(function) => { + if let Some(rules) = function.body.as_deref().and_then(returned) { + self.pixelify_css(rules); + } + } + rules => self.pixelify(rules), + } + } + + /// The value of the attribute `name` on `element`, pixelified as `css` + /// prop rules when `css` + fn pixelify_attribute(&mut self, element: &JSXOpeningElement, name: &str) { + for attribute in &element.attributes { + if let JSXAttributeItem::Attribute(attribute) = attribute + && attribute + .name + .as_identifier() + .is_some_and(|attribute| attribute.name == name) + && let Some(JSXAttributeValue::ExpressionContainer(container)) = &attribute.value + && let Some(rules) = container.expression.as_expression() + { + if name == "css" { + self.pixelify_css(rules); + } else { + self.pixelify(rules); + } + } + } + } + fn pixelify_value(&mut self, key: &str, value: &Expression) { if let Some(number) = js_number_literal(value) { if number != 0.0 && !keeps_bare_number(key) { @@ -215,6 +284,23 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { for rules in rules { self.pixelify(rules); } + if let Expression::Identifier(callee) = &call.callee + && self.jsx.contains(&callee.name.as_str()) + && let [element, Argument::ObjectExpression(props), ..] = call.arguments.as_slice() + && let Some(element) = element.as_expression() + && self + .css_prop + .takes_type(element, |root| self.devup.contains(&root)) + { + for property in &props.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property + && !property.computed + && property.key.static_name().is_some_and(|key| key == "css") + { + self.pixelify_css(&property.value); + } + } + } walk_call_expression(self, call); } @@ -222,19 +308,13 @@ impl<'a> Visit<'a> for LibraryNumbers<'_> { if let JSXElementName::IdentifierReference(name) = &element.name && self.components.contains(&name.name.as_str()) { - for attribute in &element.attributes { - if let JSXAttributeItem::Attribute(attribute) = attribute - && attribute - .name - .as_identifier() - .is_some_and(|name| name.name == "styles") - && let Some(JSXAttributeValue::ExpressionContainer(container)) = - &attribute.value - && let Some(rules) = container.expression.as_expression() - { - self.pixelify(rules); - } - } + self.pixelify_attribute(element, "styles"); + } + if self + .css_prop + .takes(&element.name, |root| self.devup.contains(&root)) + { + self.pixelify_attribute(element, "css"); } walk_jsx_opening_element(self, element); } @@ -272,6 +352,8 @@ fn devup_equivalent(source: &str, imported: &str) -> Option } (_, "keyframes") => Some(DevupTarget::Main("keyframes")), (_, "styled") => Some(DevupTarget::Main("styled")), + // The `css` props it builds compile away, leaving React's own element + ("@emotion/react", "jsx" | "createElement") => Some(DevupTarget::Compat("jsx")), (_, "createGlobalStyle") => Some(DevupTarget::Compat("createGlobalStyle")), (_, "Global") => Some(DevupTarget::Compat("Global")), (_, "ThemeProvider") => Some(DevupTarget::Compat("ThemeProvider")), @@ -294,7 +376,18 @@ pub fn transform_import_aliases<'a>( package: &str, import_aliases: &HashMap, ) -> Cow<'a, str> { - transform_import_aliases_with_edits(code, filename, package, import_aliases).0 + transform_import_aliases_with_edits(code, filename, package, import_aliases).code +} + +/// What rewriting aliased imports gives +pub struct Aliased<'a> { + /// The code, or the original code when nothing was rewritten + pub code: Cow<'a, str>, + /// The replacements made in order, so a position in the code maps back to + /// the source + pub edits: Vec, + /// Which elements take Emotion's `css` prop + pub css_prop: CssProp, } /// Packages generating CSS on top of vanilla-extract's, which Devup UI does not @@ -349,17 +442,26 @@ pub type Edit = (usize, usize, usize); /// /// # Returns /// The transformed source code, or the original code if no transformations were -/// needed, and the replacements made in order, so a position in the result maps -/// back to the source +/// needed, the replacements made in order, so a position in the result maps +/// back to the source, and which elements take Emotion's `css` prop pub fn transform_import_aliases_with_edits<'a>( code: &'a str, filename: &str, package: &str, import_aliases: &HashMap, -) -> (Cow<'a, str>, Vec) { - // Quick check: if no aliases match, return original code - if import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias)) { - return (Cow::Borrowed(code), Vec::new()); +) -> Aliased<'a> { + let unchanged = |css_prop| Aliased { + code: Cow::Borrowed(code), + edits: Vec::new(), + css_prop, + }; + let emotion_aliased = import_aliases.contains_key(EMOTION_REACT); + // Quick check: if no aliases match and no element takes a `css` prop, + // return original code + if CssProp::of(import_aliases, code, false) == CssProp::Off + && (import_aliases.is_empty() || !import_aliases.keys().any(|alias| code.contains(alias))) + { + return unchanged(CssProp::Off); } let allocator = Allocator::default(); @@ -377,10 +479,38 @@ pub fn transform_import_aliases_with_edits<'a>( transformations.extend(crate::jsx_import_source::emotion_replacements(&program)); } let mut numbers = LibraryNumbers::default(); + let mut uses_emotion = !transformations.is_empty(); + let compat = format!("{package}/compat"); for stmt in &program.body { if let Statement::ImportDeclaration(import_decl) = stmt { let source_value = import_decl.source.value.as_str(); + uses_emotion |= is_emotion(source_value); + for specifier in import_decl.specifiers.iter().flatten() { + match specifier { + ImportDeclarationSpecifier::ImportSpecifier(spec) + if is_jsx_function( + source_value, + imported_name(&spec.imported), + &compat, + ) => + { + numbers.jsx.push(spec.local.name.as_str()); + } + specifier if source_value == package => { + numbers.devup.push(specifier.local().name.as_str()); + } + _ => {} + } + } + if emotion_aliased && let Some(runtime) = react_runtime(source_value) { + let span = import_decl.source.span; + transformations.push(( + span.start as usize, + span.end as usize, + format!("\"{runtime}\""), + )); + } if let Some(alias) = import_aliases.get(source_value) && !import_decl.import_kind.is_type() @@ -435,15 +565,18 @@ pub fn transform_import_aliases_with_edits<'a>( } } } - if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) { + numbers.css_prop = CssProp::of(import_aliases, code, uses_emotion); + if !(numbers.calls.is_empty() && numbers.styled.is_empty() && numbers.components.is_empty()) + || numbers.css_prop != CssProp::Off + { numbers.visit_program(&program); transformations.append(&mut numbers.replacements); - transformations.sort_unstable_by_key(|(start, ..)| *start); } + transformations.sort_unstable_by_key(|(start, ..)| *start); // Apply transformations in reverse order to preserve positions if transformations.is_empty() { - return (Cow::Borrowed(code), Vec::new()); + return unchanged(numbers.css_prop); } let edits = transformations @@ -455,7 +588,11 @@ pub fn transform_import_aliases_with_edits<'a>( result.replace_range(start..end, &replacement); } - (Cow::Owned(result), edits) + Aliased { + code: Cow::Owned(result), + edits, + css_prop: numbers.css_prop, + } } /// Reapply Emotion units to literals produced after the initial alias pass. @@ -1347,4 +1484,39 @@ const x = 1;", ); } } + + #[test] + fn test_css_prop_numbers_runtimes_and_pragmas() { + let mut aliases = emotion_alias(); + aliases.insert(EMOTION_REACT.to_string(), ImportAlias::NamedToNamed); + let outputs: Vec<(String, CssProp)> = [ + "export const a =
;", + "export const a =
;", + "export const a =
({ top: 1 }), t => { return { left: 2 }; }, t => { f(); return { right: 3 }; }, function (t) { return { bottom: 4 }; }, function (t) { f(); }]} />;", + "import { Box } from '@devup-ui/react';\nexport const a = <>;", + "import styled from '@emotion/styled';\nexport const a = ;", + "/** @jsxImportSource @emotion/react */\nexport const a = ;", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nimport { jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 }, id: 1, ['css']: { top: 1 } }), jsxDEV(Custom, { css: { top: 3 } }), _jsx('div', props), f('div', { css: { top: 4 } })];", + "import { jsx, createElement } from '@emotion/react';\nexport const a = [jsx('div', { css: { padding: 2 } }), createElement('div', null)];", + "import { jsx as _jsx } from 'react/jsx-runtime';\nexport const a = [_jsx('div', { css: { padding: 2 } }), _jsx(Custom, { css: { padding: 2 } })];", + "export const a = { css: 1 };", + ] + .iter() + .map(|code| { + let aliased = + transform_import_aliases_with_edits(code, "test.tsx", "@devup-ui/react", &aliases); + (aliased.code.into_owned(), aliased.css_prop) + }) + .collect(); + insta::assert_debug_snapshot!(outputs); + + let without_css_prop = transform_import_aliases_with_edits( + "export const a =
;", + "test.tsx", + "@devup-ui/react", + &emotion_alias(), + ); + assert_eq!(without_css_prop.css_prop, CssProp::Off); + assert!(matches!(without_css_prop.code, Cow::Borrowed(_))); + } } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 8bb69fb67..1c2f85933 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -22,6 +22,7 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::composition::{Composition, set_prop_order}; +use crate::css_prop::{CssProp, CssTakers}; use crate::extractor::ExtractResult; use crate::extractor::extract_style_from_expression::{ LiteralHandling, extract_style_from_expression, @@ -369,11 +370,13 @@ pub(crate) fn inline_constants<'a>( filename: &str, option: &ExtractOption, resolver: Option<&ModuleResolver>, + css_prop: CssProp, ) -> Inlined { let is_style_package = |source: &str| source.starts_with(&option.package) || source == crate::STYLEX_PACKAGE; let mut style_roots = FxHashSet::default(); let mut apis = StyleApis::default(); + let css_props = CssTakers::new(program, css_prop, &format!("{}/compat", option.package)); for statement in &program.body { if let Statement::ImportDeclaration(import) = statement && is_style_package(&import.source.value) @@ -395,11 +398,12 @@ pub(crate) fn inline_constants<'a>( } } } - if style_roots.is_empty() { + if style_roots.is_empty() && css_prop == CssProp::Off { return Inlined::default(); } let mut read = StyleReads { style_roots: &style_roots, + css_props: &css_props, names: FxHashSet::default(), depth: 0, }; @@ -420,6 +424,7 @@ pub(crate) fn inline_constants<'a>( scope .style_names .extend(style_roots.iter().map(ToString::to_string)); + scope.css_props = Some(&css_props); let mut bindings: FxHashMap<&str, Vec<&Cell>>> = FxHashMap::default(); for statement in &program.body { let declaration = match statement { @@ -548,10 +553,12 @@ pub(crate) fn inline_constants<'a>( scoping: &scoping, symbols: &symbols, style_roots: &style_roots, + css_props: &css_props, apis: &apis, css_failures: &mut inlined.css_failures, objects: false, styles: false, + px: false, } .visit_program(program); } @@ -683,6 +690,7 @@ impl StyleApis<'_> { /// of its functions struct StyleReads<'s> { style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, names: FxHashSet, depth: usize, } @@ -721,9 +729,21 @@ impl<'a> Visit<'a> for StyleReads<'_> { fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) { self.visit_expression(&call.callee); let style = self.is_style_root(&call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading(style, |reads| { - for argument in &call.arguments { - reads.visit_argument(argument); + for (index, argument) in call.arguments.iter().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter().enumerate() { + reads.reading(at == css, |reads| { + reads.visit_object_property_kind(property); + }); + } + } + _ => reads.visit_argument(argument), + } } }); } @@ -739,20 +759,22 @@ impl<'a> Visit<'a> for StyleReads<'_> { fn visit_jsx_opening_element(&mut self, element: &oxc_ast::ast::JSXOpeningElement<'a>) { let style = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); - self.reading(style, |reads| { - for attribute in &element.attributes { - match attribute { - JSXAttributeItem::Attribute(attribute) => { - if let Some(value) = &attribute.value { - reads.visit_jsx_attribute_value(value); - } - } - JSXAttributeItem::SpreadAttribute(spread) => { - reads.visit_expression(&spread.argument); + for attribute in &element.attributes { + let style = style + || self.css_props.attribute(&element.name, attribute, |root| { + self.style_roots.contains(root) + }); + self.reading(style, |reads| match attribute { + JSXAttributeItem::Attribute(attribute) => { + if let Some(value) = &attribute.value { + reads.visit_jsx_attribute_value(value); } } - } - }); + JSXAttributeItem::SpreadAttribute(spread) => { + reads.visit_expression(&spread.argument); + } + }); + } } } @@ -1071,6 +1093,8 @@ struct ModuleScope<'p, 'a> { style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given style_names: FxHashSet, + /// The `css` props of the file extracted, which never run what they hold + css_props: Option<&'p CssTakers<'p>>, uses: Option>>>, changes: FxHashMap>>, } @@ -1088,6 +1112,7 @@ impl<'p, 'a> ModuleScope<'p, 'a> { imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), + css_props: None, uses: None, changes: FxHashMap::default(), } @@ -1235,9 +1260,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { uses.clone() } else { let option = modules.option; - let uses = Rc::new(crate::mutations::uses(self.program, &|name| { - self.is_style_import(option, name) - })); + let uses = Rc::new(crate::mutations::uses( + self.program, + &|name| self.is_style_import(option, name), + self.css_props, + )); self.uses = Some(uses.clone()); uses }; @@ -1879,12 +1906,15 @@ struct Inline<'s, 'a> { scoping: &'s Scoping, symbols: &'s FxHashMap, style_roots: &'s FxHashSet<&'s str>, + css_props: &'s CssTakers<'s>, apis: &'s StyleApis<'s>, css_failures: &'s mut Vec, /// Inside what the build reads as style objects objects: bool, /// Inside the arguments of a style API or a style prop styles: bool, + /// Inside a `css` prop, whose numbers Emotion reads as `px` lengths + px: bool, } impl<'a> Inline<'_, 'a> { @@ -2040,6 +2070,59 @@ impl<'a> Inline<'_, 'a> { self.styles = outer; result } + + /// `visit` reading a `css` prop when `css` + fn reading_css(&mut self, css: bool, visit: impl FnOnce(&mut Self) -> T) -> T { + let outer = std::mem::replace(&mut self.px, css); + let result = self.reading_styles(css, |inline| inline.reading_objects(css, visit)); + self.px = outer; + result + } +} + +/// `property` holding a number as the `px` length Emotion reads it as +fn px_value<'a>(ast_builder: &AstBuilder<'a>, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + if let Some(number) = crate::utils::js_number_literal(&property.value) + && number != 0.0 + && property + .key + .static_name() + .is_some_and(|key| !crate::utils::keeps_bare_number(&key)) + { + property.value = Expression::new_string_literal( + SPAN, + Str::from_in(format!("{number}px").as_str(), ast_builder.allocator()), + None, + ast_builder, + ); + } +} + +/// The rules `rules` with their numbers as the `px` lengths Emotion reads them +/// as, nested rules included +fn px_rules<'a>(ast_builder: &AstBuilder<'a>, rules: &mut Expression<'a>) { + let mut pending = vec![rules]; + while let Some(rules) = pending.pop() { + match rules { + Expression::ObjectExpression(object) => { + for property in &mut object.properties { + if let ObjectPropertyKind::ObjectProperty(property) = property { + px_value(ast_builder, property); + pending.push(&mut property.value); + } + } + } + Expression::ArrayExpression(array) => { + pending.extend( + array + .elements + .iter_mut() + .filter_map(|element| element.as_expression_mut()), + ); + } + _ => {} + } + } } /// `constant` written as a literal, objects and arrays too when `objects` @@ -2106,6 +2189,9 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } if let Some(literal) = self.literal(&constant) { *expression = literal; + if self.px { + px_rules(self.ast_builder, expression); + } return; } } @@ -2139,10 +2225,22 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { self.visit_expression(&mut call.callee); let objects = self.apis.reads(&call.callee); let styles = is_style_root(self.style_roots, &call.callee); + let css = self + .css_props + .property(call, |root| self.style_roots.contains(root)); self.reading_styles(styles, |inline| { inline.reading_objects(objects, |inline| { - for argument in &mut call.arguments { - inline.visit_argument(argument); + for (index, argument) in call.arguments.iter_mut().enumerate() { + match (css, argument) { + (Some(css), Argument::ObjectExpression(props)) if index == 1 => { + for (at, property) in props.properties.iter_mut().enumerate() { + inline.reading_css(at == css, |inline| { + inline.visit_object_property_kind(property); + }); + } + } + (_, argument) => inline.visit_argument(argument), + } } }); }); @@ -2150,7 +2248,14 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { fn visit_jsx_opening_element(&mut self, element: &mut oxc_ast::ast::JSXOpeningElement<'a>) { let styled = jsx_root(&element.name).is_some_and(|root| self.style_roots.contains(root)); + let element_name = &element.name; for attribute in &mut element.attributes { + if self.css_props.attribute(element_name, attribute, |root| { + self.style_roots.contains(root) + }) { + self.reading_css(true, |inline| inline.visit_jsx_attribute_item(attribute)); + continue; + } let objects = styled && match attribute { JSXAttributeItem::Attribute(attribute) => { @@ -2168,9 +2273,15 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } fn visit_object_property(&mut self, property: &mut oxc_ast::ast::ObjectProperty<'a>) { + let inlined_number = self.px + && self.styles + && matches!(self.constant(&property.value), Some(Constant::Number(_))); walk_mut::walk_object_property(self, property); if property.shorthand && !matches!(property.value, Expression::Identifier(_)) { property.shorthand = false; } + if inlined_number { + px_value(self.ast_builder, property); + } } } diff --git a/libs/extractor/src/jsx_import_source.rs b/libs/extractor/src/jsx_import_source.rs index 156ae78b3..6b14f8600 100644 --- a/libs/extractor/src/jsx_import_source.rs +++ b/libs/extractor/src/jsx_import_source.rs @@ -84,7 +84,9 @@ mod tests { "{header}\nexport const anchor=1;\n/** @jsxImportSource @emotion/react */\nexport const later=2;" ); - let (rewritten, _) = transform_import_aliases_with_edits( + let crate::import_alias_visit::Aliased { + code: rewritten, .. + } = transform_import_aliases_with_edits( &source, "pragma.tsx", "@devup-ui/react", diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index b80263c7a..8f6106253 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -3,6 +3,9 @@ mod build_time_values; mod class_arguments; mod component; mod composition; +mod css_prop; +#[cfg(test)] +mod css_prop_contract_tests; mod css_utils; mod emotion_namespace; pub mod extract_style; @@ -376,21 +379,36 @@ fn extract_source( css::atom_hoist::freeze_atom_plan(); // e.g., `import styled from '@emotion/styled'` → `import { styled } from '@devup-ui/react'` // e.g., `import { style } from '@vanilla-extract/css'` → `import { style } from '@devup-ui/react'` - let (transformed_code, alias_edits) = import_alias_visit::transform_import_aliases_with_edits( + let import_alias_visit::Aliased { + code: transformed_code, + edits: alias_edits, + css_prop, + } = import_alias_visit::transform_import_aliases_with_edits( &namespace_code, filename, &option.package, &option.import_aliases, ); - // Step 2: Check if code contains the target package (after transformation) + // Step 2: Check if code contains the target package (after transformation), + // gives an element a `css` prop, or had an import rewritten let has_relevant_import = transformed_code.contains(option.package.as_str()) || transformed_code.contains(STYLEX_PACKAGE); + let unchanged = || ExtractOutput { + styles: FxHashSet::default(), + code: code.to_string(), + map: None, + css_file: None, + dependencies: Vec::new(), + }; - if !has_relevant_import { - let pragma_rewritten = alias_edits - .iter() - .any(|(start, end, _)| &namespace_code[*start..*end] == "@emotion/react"); + let pragma_rewritten = alias_edits + .iter() + .any(|(start, end, _)| &namespace_code[*start..*end] == "@emotion/react"); + if !has_relevant_import + && css_prop == css_prop::CssProp::Off + && (alias_edits.is_empty() || pragma_rewritten) + { // skip if not using package if namespace_edits.is_empty() && !pragma_rewritten { return Ok(ExtractOutput { @@ -536,6 +554,7 @@ fn extract_source( filename, &option, resolver, + css_prop, ) } else { imported_constants::Inlined::default() @@ -561,7 +580,16 @@ fn extract_source( visitor.import_css(inlined.css_styles); visitor.unknown_bindings(&inlined.unknown); visitor.changed_bindings(inlined.changed.clone()); + visitor.takes_css_prop(css_prop); visitor.visit_program(&mut program); + if !has_relevant_import + && alias_edits.is_empty() + && namespace_edits.is_empty() + && !visitor.compiled_css_prop + { + // No element took the `css` prop the text seemed to give + return Ok(unchanged()); + } if let Some(error) = evaluation_error && imports_uncompiled(&program, &option.package) { @@ -15487,7 +15515,7 @@ const Button = styled.button({ bg: 'red' }) )] #[case( "css(yellow, fade)", - r"`color-0-red-_a__c_hover-255 color-0-yellow--255 ${fade}`" + r#""k-525370705245237976 color-0-red-_a__c_hover-255 color-0-yellow--255""# )] #[case( "css(yellow, { color: ['x', 'y'][idx], styleOrder: 2 })", @@ -23327,6 +23355,205 @@ export const App = () => ExtractOption { + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::from([ + ("@emotion/react".to_string(), ImportAlias::NamedToNamed), + ( + "@emotion/styled".to_string(), + ImportAlias::DefaultToNamed("styled".to_string()), + ), + ]), + } + } + + fn emotion_outputs(files: &[(&str, &str)]) -> Vec<(String, ToBTreeSet)> { + files + .iter() + .map(|(filename, code)| { + reset_class_map(); + reset_file_map(); + ( + (*code).to_string(), + ToBTreeSet::from(extract(filename, code, emotion_option()).unwrap()), + ) + }) + .collect() + } + + #[test] + #[serial] + fn test_emotion_css_prop_in_every_form() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "export const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ danger }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ active }) =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst style = css`color: red;`;\nexport const App = () =>
;" + ), + ( + "test.tsx", + "/** @jsxImportSource @emotion/react */\nexport const App = () =>
;" + ), + ( + "test.ts", + "import { jsx } from '@emotion/react';\nexport const App = () => jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsx('div', { css: { color: 'red' } });" + ), + ( + "test.ts", + "import { jsxs as _jsxs } from '@emotion/react/jsx-runtime';\nexport const App = () => _jsxs('div', { css: { color: 'red' }, children: ['a', 'b'] });" + ), + ( + "test.ts", + "import { jsxDEV as _jsxDEV } from '@emotion/react/jsx-dev-runtime';\nexport const App = () => _jsxDEV('div', { css: { padding: 4 } }, void 0, false);" + ), + ( + "test.tsx", + "/** @jsx jsx */\nimport { jsx } from '@emotion/react';\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = () =>
({ color: theme.colors.primary, padding: 8 })} />;" + ), + ( + "test.tsx", + "export const App = () =>
{ return { color: colors.text }; }} />;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst override = css({ color: 'blue' });\nexport const App = () =>
;" + ), + ( + "test.tsx", + "import { css } from '@emotion/react';\nconst base = css({ color: 'blue', margin: 0 });\nexport const App = ({ cls }) => <>
;" + ), + ( + "test.tsx", + "export const App = ({ c, w }) =>
;" + ), + ( + "test.tsx", + "export const App = ({ c }) => <>
;" + ), + ( + "test.tsx", + "const base = { color: 'red', padding: 4 };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "const SPACE = 4;\nconst base = { padding: 4, lineHeight: 1.5, '&:hover': { margin: 2 } };\nexport const App = () =>
;" + ), + ( + "test.tsx", + "export const App = ({ cls, s }) => <>
css={{ color: 'red' }} />
;" + ), + ( + "test.ts", + "import { jsx as _jsx } from '@emotion/react/jsx-runtime';\nconst base = { padding: 2 };\nconst SPACE = 3;\nexport const App = ({ c, s }) => [_jsx('div', { id: 1 }), _jsx('div', { style: s, css: [base, { margin: SPACE, color: c }] })];" + ), + ])); + } + + #[test] + #[serial] + fn test_emotion_css_prop_on_every_element() { + assert_debug_snapshot!(emotion_outputs(&[ + ( + "test.tsx", + "const Custom = ({ className }) =>
;\nexport const App = () => ;" + ), + ("test.tsx", "export const a = { css: 1 };"), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Custom = ({ className }) =>
;\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import { Box } from '@devup-ui/react';\nexport const App = () => <>;" + ), + ( + "test.tsx", + "import styled from '@emotion/styled';\nconst Button = styled.button({ color: 'blue', margin: 1 });\nexport const App = ({ f, rest, cls }) => <>