From 0edb2118e4621866905b8cbca2d8cc57b3f344ac Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 22:22:47 +0900 Subject: [PATCH 1/5] fix(extractor): compile or reject function-local style objects, keep ClassNames cx calls, read style functions of props Refs #690, #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_local_style_objects.json | 7 + libs/extractor/src/css_utils.rs | 11 +- .../extractor/extract_style_from_styled.rs | 189 +++++++++ libs/extractor/src/import_alias_visit.rs | 14 + libs/extractor/src/imported_constants.rs | 121 +++++- libs/extractor/src/lib.rs | 123 +++++- .../extractor__tests__changed_constants.snap | 2 +- ...on_class_names_keeps_calls_as_classes.snap | 9 + ..._names_reports_what_it_cannot_compile.snap | 9 +- ...__style_objects_declared_in_functions.snap | 86 +++++ ...lared_in_functions_of_other_libraries.snap | 50 +++ ...the_props_and_arrays_under_conditions.snap | 102 +++++ ...led_object_form_theme_interpolation-2.snap | 18 + libs/extractor/src/utils.rs | 10 +- libs/extractor/src/visit.rs | 360 ++++++++++++++---- 15 files changed, 1014 insertions(+), 97 deletions(-) create mode 100644 .changepacks/changepack_log_local_style_objects.json create mode 100644 libs/extractor/src/snapshots/extractor__tests__emotion_class_names_keeps_calls_as_classes.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions_of_other_libraries.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_functions_of_the_props_and_arrays_under_conditions.snap create mode 100644 libs/extractor/src/snapshots/extractor__tests__styled_object_form_theme_interpolation-2.snap diff --git a/.changepacks/changepack_log_local_style_objects.json b/.changepacks/changepack_log_local_style_objects.json new file mode 100644 index 000000000..439e30dc2 --- /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 to css(), styled(), globalCss(), keyframes(), a style prop or a spread (also through the Emotion, styled-components and vanilla-extract aliases) now compiles when every entry is written out and the object is only read as styles, instead of leaving the object where the class belongs; the build errors, at the binding, when the object is also read elsewhere (where code may change it), and at the call when it holds values only the runtime gives. Emotion's ClassNames cx keeps a function call's result as a class that stays as written, as @emotion/css cx does. css() and styled() compose an array of rule objects under a condition. A style function of the props (styled.div((props) => ({ ... })), and as a later argument) compiles: each value reading the props becomes a CSS variable the component sets (a number is a px length unless the property is unitless), theme reads (including theme.a[2]) become var(--a-2), and a condition chooses per value. New build errors: styled(...).attrs(...) chained after the styles (chain .attrs before them), a style function of the props that does not give one rule object at once with every entry written out, and a style object declared in a function that is also read elsewhere", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index ab3239a64..de8628708 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -74,7 +74,16 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { loop { match cursor { Expression::StaticMemberExpression(member) => { - path.push(member.property.name.as_str()); + path.push(member.property.name.to_string()); + cursor = &member.object; + } + Expression::ComputedMemberExpression(member) => { + path.push(match &member.expression { + Expression::StringLiteral(key) => key.value.to_string(), + key => { + crate::utils::js_number_literal(key).map(crate::utils::js_number_string)? + } + }); cursor = &member.object; } Expression::Identifier(ident) => { diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index c1ceb13df..fa72b2a5d 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -31,6 +31,180 @@ use oxc_ast::{ use oxc_span::{GetSpan, SPAN}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; +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 })`"; + +/// The rules `style`, an argument of a styled component, gives, with each +/// value a function of the props written as the value of a CSS variable the +/// component sets: a function giving rules becomes the rules with each value +/// read from the props as such a function, so a condition chooses per value. +/// `Err` holds a function the build cannot read so. +fn rules_reading_props<'a>( + ast_builder: &AstBuilder<'a>, + style: &mut Expression<'a>, +) -> Result<(), Expression<'a>> { + use oxc_allocator::TakeIn; + + if matches!( + unwrap_syntax_only(style), + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) + ) { + let code = style.clone_in(ast_builder.allocator()); + let (params, returned) = crate::css_prop::render_function(style) + .ok_or_else(|| code.clone_in(ast_builder.allocator()))?; + let params = params.clone_in_with_semantic_ids(ast_builder.allocator()); + let mut rules = returned.take_in(ast_builder); + let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(&mut rules) else { + return Err(code); + }; + props_leaves(ast_builder, object, ¶ms).ok_or(code)?; + *style = rules; + } + if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(style) { + call_with_props(ast_builder, object); + } + Ok(()) +} + +/// Whether `value` is a literal, which reads the same given any props +fn is_written_out(value: &Expression<'_>) -> bool { + let value = unwrap_syntax_only(value); + crate::utils::get_string_by_literal_expression(value).is_some() + || crate::utils::js_number_literal(value).is_some() + || matches!( + value, + Expression::BooleanLiteral(_) | Expression::NullLiteral(_) + ) +} + +/// The values of `object` that read the props, each as a function taking the +/// parameters `params` of the function that gave the rules +fn props_leaves<'a>( + ast_builder: &AstBuilder<'a>, + object: &mut oxc_ast::ast::ObjectExpression<'a>, + params: &FormalParameters<'a>, +) -> Option<()> { + use oxc_allocator::TakeIn; + + for property in &mut object.properties { + 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)?; + match unwrap_syntax_only_mut(&mut property.value) { + Expression::ObjectExpression(inner) => props_leaves(ast_builder, inner, params)?, + Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => {} + value if is_written_out(value) => {} + _ => { + let leaf = property.value.take_in(ast_builder); + property.value = props_function(ast_builder, params, leaf, &key); + } + } + } + Some(()) +} + +/// `(params) => leaf`: a number is a `px` length, as Emotion reads it, unless +/// the property takes numbers as they are or the function reads the theme +fn props_function<'a>( + ast_builder: &AstBuilder<'a>, + params: &FormalParameters<'a>, + leaf: Expression<'a>, + key: &str, +) -> Expression<'a> { + let function = |body: Expression<'a>| { + Expression::new_arrow_function_expression( + SPAN, + false, + None::>>, + oxc_allocator::Box::new_in( + params.clone_in_with_semantic_ids(ast_builder.allocator()), + ast_builder, + ), + None::>>, + body.into(), + ast_builder, + ) + }; + let plain = function(leaf.clone_in_with_semantic_ids(ast_builder.allocator())); + if crate::utils::keeps_bare_number(key) + || crate::css_utils::theme_var_reference(&plain).is_some() + { + return plain; + } + let value = || identifier(ast_builder, "__devupValue"); + let is_number = Expression::new_binary_expression( + SPAN, + Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, value(), ast_builder), + BinaryOperator::StrictEquality, + Expression::new_string_literal(SPAN, "number", None, ast_builder), + ast_builder, + ); + let length = Expression::new_binary_expression( + SPAN, + value(), + BinaryOperator::Addition, + Expression::new_string_literal(SPAN, "px", None, ast_builder), + ast_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, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_syntax::number::NumberBase::Decimal, + ast_builder, + ), + ast_builder, + ), + ast_builder, + ), + length, + value(), + ast_builder, + ); + let length = named_arrow(ast_builder, "__devupValue", length); + function(wrap_direct_call(ast_builder, &length, &[leaf])) +} + +/// The values of `object` that are functions of the props written as the +/// result of calling them with the props the component renders, which the +/// styles read as the values of CSS variables; a function reading the theme +/// stays, as the styles read it as a variable of the theme +fn call_with_props<'a>( + ast_builder: &AstBuilder<'a>, + object: &mut oxc_ast::ast::ObjectExpression<'a>, +) { + 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(ast_builder, inner), + function @ (Expression::ArrowFunctionExpression(_) + | Expression::FunctionExpression(_)) + if crate::css_utils::theme_var_reference(function).is_none() => + { + *function = + wrap_direct_call(ast_builder, function, &[identifier(ast_builder, "rest")]); + } + _ => {} + } + } +} + /// The binding a styled component reads the component it renders through, when /// only the runtime gives that component const STYLED_BASE: &str = "DevupStyled"; @@ -508,6 +682,21 @@ pub fn extract_style_from_styled<'a>( if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { + for argument in &mut call.arguments { + if let Some(rules) = argument.as_expression_mut() + && let Err(code) = rules_reading_props(ast_builder, rules) + { + errors.push(( + code.span().start, + build_time_error("styled", &readable_code(&code), PROPS_FUNCTION), + )); + *rules = Expression::new_object_expression( + SPAN, + oxc_allocator::Vec::new_in(ast_builder), + ast_builder, + ); + } + } match style_arguments(ast_builder, &call.arguments) { Some(StyleArguments { classes, rules }) => { call.arguments = diff --git a/libs/extractor/src/import_alias_visit.rs b/libs/extractor/src/import_alias_visit.rs index 83f55d76d..3e2dc79f1 100644 --- a/libs/extractor/src/import_alias_visit.rs +++ b/libs/extractor/src/import_alias_visit.rs @@ -117,6 +117,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 05e0ddad0..9454252ee 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -165,6 +165,82 @@ pub(crate) struct Inlined { pub css_styles: FxHashMap>, 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)>, +} + +/// 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 @@ -364,6 +440,11 @@ pub(crate) fn inline_constants<'a>( if read.names.is_empty() { return Inlined::default(); } + let mut locals = LocalConstants::default(); + locals.visit_program(program); + locals + .found + .retain(|local| read.names.contains(&local.name)); let mut modules = Modules { resolver, option, @@ -439,7 +520,10 @@ pub(crate) fn inline_constants<'a>( // Scoping is only worth building when a style reads a name that may // hold a constant let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { + if !reads_math + && locals.found.is_empty() + && !read.names.iter().any(|name| scope.binds(name)) + { return Inlined::default(); } let scoping = SemanticBuilder::new() @@ -500,8 +584,21 @@ pub(crate) fn inline_constants<'a>( (scoping, 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: &scoping, symbols: &symbols, @@ -512,9 +609,18 @@ pub(crate) fn inline_constants<'a>( styles: false, px: false, marks: px, + replaced: FxHashMap::default(), class_names: Vec::new(), + }; + inline.visit_program(program); + for (symbol, local) in declared { + let read = inline.replaced.get(&symbol).copied().unwrap_or(0); + 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 } @@ -1817,6 +1923,9 @@ 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, + /// How often a style read each binding below the top level, by what it + /// held + replaced: FxHashMap, /// The names the `` child functions around take `css` and /// `cx` by class_names: Vec, @@ -2088,6 +2197,12 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { { let span = expression.span(); let marked = self.marks.contains(&(span.start, span.end)); + if let Expression::Identifier(identifier) = &*expression + && let Some(reference) = identifier.reference_id.get() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + *self.replaced.entry(symbol).or_default() += 1; + } *expression = literal; if marked { px_number(self.ast_builder, expression); diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 06a866d2c..4a7500b22 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -451,6 +451,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); @@ -18241,16 +18242,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] @@ -21533,7 +21532,8 @@ export const App = () => {({ theme }) => };", "export const App = () => {({ css, theme }) => };", "export const App = () => {({ css }) => };", - "export const App = () => {({ cx }) => };", + "export const App = () => {({ css }) => };", + "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ cx }) => };", "export const App = () => {({ cx }) => };", "export const App = ({ s }) => {({ css }) => };", @@ -21676,4 +21676,115 @@ export const App = () => = [ + "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); }", + ] + .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(); + match extract("test.tsx", &format!("{import}{code}"), emotion_option()) { + Ok(output) => format!("{:?}", ToBTreeSet::from(output)), + Err(error) => 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 + )); + } } 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__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..ac15ef076 --- /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 +--- +[ + "ToBTreeSet { styles: {}, code: \"export const App = () => ;\\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 App = ({ on }) => ;\\n\" }", + "ToBTreeSet { styles: {}, code: \"export const App = ({ o }) => ;\\n\" }", +] 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..a9300815d 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,12 +23,13 @@ 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: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:66: `` cannot use `css(getClass())` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", + "test.tsx:2:65: `` cannot use `cx(new Klass())` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", "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", + "test.tsx:2:71: `` cannot use `css(...s)` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", "test.tsx:2:87: `` cannot use `v` at build time: a mixin must stand outside nested rules, where the parts it composes with can be split", "test.tsx:2:71: `css()` cannot use `v` at build time: its values must be literals, theme tokens or constants, or be computed from them", - "test.tsx:2:76: `` cannot use `cx(on && cx(css({ color: \"red\" }), \"x\"))` 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:81: `` cannot use `cx(on && cx(off ? css({ color: \"red\" }) : null))` 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:76: `` cannot use `cx(on && cx(css({ color: \"red\" }), \"x\"))` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", + "test.tsx:2:81: `` cannot use `cx(on && cx(off ? css({ color: \"red\" }) : null))` at build time: `css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written", ] 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..6afe04078 --- /dev/null +++ b/libs/extractor/src/snapshots/extractor__tests__style_objects_declared_in_functions.snap @@ -0,0 +1,86 @@ +--- +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)} 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)} 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:69: `globalCss()` cannot use `inner` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/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", + ), + ( + "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:71: `keyframes()` cannot use `inner` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/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", + ), + ( + "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\" }", + ), +] 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..c31f6ff3c --- /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)} 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)} 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..feed264fa --- /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: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.c))(rest)\", 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)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--c\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.c))(rest) },\\n\\t...style\\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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b c\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\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: \"(()=>`x`)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", 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)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (()=>`x`)(rest),\\n\\t\\t\\\"--d\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\\n\\t},\\n\\t...style\\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: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.color))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"line-height\", level: 0, identifier: \"(p=>p.lh)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(p=>p.w)(rest)\", 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)} as={forwardedAs} className={[\\\"a c d f\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.color))(rest),\\n\\t\\t\\\"--e\\\": (p=>p.lh)(rest),\\n\\t\\t\\\"--g\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"h\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--i\\\": (p=>p.w)(rest) },\\n\\t...style\\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 }) => __devupDom)(rest)} as={forwardedAs} className={[`${on ? \\\"a\\\" : \\\"\\\"} ${on ? on ? \\\"b\\\" : \\\"\\\" : \\\"c\\\"} ${off ? \\\"d\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\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 }) => __devupDom)(rest)} as={forwardedAs} className={[`${on ? \\\"a\\\" : \\\"\\\"} ${on ? \\\"b\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\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) => `${on ? typeof on === \\\"string\\\" ? on : \\\"\\\" : \\\"\\\"} ${`${on ? \\\"a\\\" : \\\"b\\\"} ${on ? \\\"\\\" : \\\"c\\\"} ${on ? \\\"\\\" : \\\"d\\\"}`}`;\\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: \"(p=>p.color)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>p.h)(rest)\", 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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>p.color)(rest),\\n\\t\\t\\\"--e\\\": (p=>p.h)(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({ 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)} as={forwardedAs} className={[\\\"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: \"(p=>p.on?`red`:`blue`)(rest)\", 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)} as={forwardedAs} className={[\\\"a\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--b\\\": (p=>p.on?`red`:`blue`)(rest) },\\n\\t...style\\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) => `b ${cond ? \\\"a\\\" : \\\"\\\"}`;\\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:29: `styled()` cannot use `({ on }) => on && [{ 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({ 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: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest)\", 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)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--b\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest) },\\n\\t...style\\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: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.w))(rest)\", 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)} as={forwardedAs} className={[\\\"a b d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--c\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest),\\n\\t\\t\\\"--e\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.w))(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`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)} as={forwardedAs} className={[\\\"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: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.h))(rest)\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.on?`red`:`blue`))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", 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)} as={forwardedAs} className={[\\\"a c e\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.on?`red`:`blue`))(rest),\\n\\t\\t\\\"--d\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest),\\n\\t\\t\\\"--f\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.h))(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(({ 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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\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..a272ec484 --- /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: "(p=>p.color)(rest)", + 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 }) => __devupDom)(rest)} as={forwardedAs} className={[\"a-a\", className].filter(Boolean).join(\" \")} style={{\n\t...{ \"--a\": (p=>p.color)(rest) },\n\t...style\n}} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\n", +} diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index e5fb825c0..56f316dee 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -963,6 +963,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 = @@ -970,7 +978,7 @@ pub(super) const CLASS_NAMES_CHILD: &str = pub(super) const CLASS_NAMES_CALL: &str = "the `css` and `cx` its child function takes can only be called, as the build compiles each call"; -pub(super) const CLASS_NAMES_PART: &str = "`css` and `cx` compose only style objects, CSS text, classes, calls of them, or arrays or conditions of these"; +pub(super) const CLASS_NAMES_PART: &str = "`css` composes only style objects, CSS text, classes, calls of `css` and `cx`, or arrays or conditions of these, and `cx` also classes a function call gives, which stay as written"; pub(super) const CLASS_NAMES_CLASS_MAP: &str = "an object `cx` takes must give each class a condition, as `{ name: condition }`"; diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index f4ae87dd7..0011e723f 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -118,6 +118,33 @@ struct JsxCss<'a> { renders: bool, } +/// Whether `expression` is a condition choosing an array of styles, which +/// compose one after another rather than set the breakpoints of a value +fn composes_array_under_condition(expression: &Expression<'_>) -> bool { + match unwrap_syntax_only(expression) { + Expression::ArrayExpression(array) => array.elements.iter().any(|element| { + element + .as_expression() + .is_some_and(composes_array_under_condition) + }), + Expression::ConditionalExpression(conditional) => { + [&conditional.consequent, &conditional.alternate] + .into_iter() + .any(|side| { + matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) + || composes_array_under_condition(side) + }) + } + Expression::LogicalExpression(logical) => { + [&logical.left, &logical.right].into_iter().any(|side| { + matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) + || composes_array_under_condition(side) + }) + } + _ => false, + } +} + /// `false ?? right` is `false`, which composes nothing fn coalesce_keeps_left(logical: &oxc_ast::ast::LogicalExpression<'_>) -> bool { logical.operator == LogicalOperator::Coalesce @@ -283,6 +310,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, } /// Whether `declarator` only aliases what the build compiles away @@ -404,9 +434,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; } @@ -416,6 +446,7 @@ impl<'a> DevupVisitor<'a> { } self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); + self.local_arguments("css", &call.arguments); let (result, known) = self.composed_class(call.span.start, parts); if let Some(known) = known { self.css_styles = Some((call.span.start, known)); @@ -423,6 +454,137 @@ impl<'a> DevupVisitor<'a> { 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 { + 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 is_styled_name = |name: &str| self.styled_imports.contains(name); + + let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) else { + return false; + }; + if let Expression::StaticMemberExpression(member) = tag_or_call { + matches!(&member.object, Expression::Identifier(ident) if is_styled_name(ident.name.as_str())) + } else if let Expression::CallExpression(call) = tag_or_call { + matches!(&call.callee, Expression::Identifier(ident) if is_styled_name(ident.name.as_str())) + } else if let Expression::Identifier(ident) = 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. + is_styled_name(ident.name.as_str()) && argument_count == 2 + } else { + false + } + } + + /// `part` of what a styled component composes, with each array a condition + /// chooses written as a condition on each of its parts, which the styles + /// of a styled component read one by one. The conditions are read again + /// for each part, so they must give the same value each time. + fn distribute_conditions(&self, part: &mut Expression<'a>) { + let clone = |expression: &Expression<'a>| expression.clone_in(self.ast.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, &self.ast); + 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() { + self.distribute_conditions(element); + } + } + return; + } + Expression::LogicalExpression(logical) + if logical.operator == LogicalOperator::And + && is_pure(&logical.left) + && let Some(parts) = parts_of(&logical.right) => + { + parts + .into_iter() + .map(|part| { + Expression::new_logical_expression( + SPAN, + clone(&logical.left), + LogicalOperator::And, + part, + &self.ast, + ) + }) + .collect() + } + Expression::ConditionalExpression(conditional) + if is_pure(&conditional.test) + && 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, + &self.ast, + ) + }; + 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), &self.ast), + &self.ast, + ); + self.distribute_conditions(part); + } + + /// 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) @@ -742,6 +904,13 @@ impl<'a> DevupVisitor<'a> { self.folded_side(expression, text) } Expression::ArrayExpression(_) => self.folded_side(expression, text), + // What a call gives the `cx` of `` is a class the build + // does not know, which stays as written + Expression::CallExpression(_) + if text == Text::Classes && !self.class_names_scope.is_empty() => + { + Some(KnownSide::Class(code())) + } _ => None, } } @@ -877,6 +1046,7 @@ impl<'a> DevupVisitor<'a> { css_prop: CssProp::Off, compiled_css_prop: false, local_styles: FxHashSet::default(), + local_objects: FxHashSet::default(), } } @@ -1233,6 +1403,36 @@ 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; + } + let mut errors = Vec::new(); + 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) { + errors.push(( + part.span().start, + build_time_error(api, &readable_code(part), LOCAL_STYLES), + )); + } + } + self.errors.extend(errors); + } + /// Whether `part` reads a binding `local_styles` holds fn reads_local_styles(&self, part: &Expression<'a>) -> bool { let mut root = part; @@ -1652,6 +1852,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 @@ -2490,6 +2691,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Some(error) = forward_error { self.errors.push(error); } + self.chained_attrs(it); + if self.is_styled_call(it) + && let Expression::CallExpression(call) = it + { + for argument in &mut call.arguments { + if let Some(part) = argument.as_expression_mut() { + self.distribute_conditions(part); + } + } + } let flattened = match it { Expression::CallExpression(call) if self.is_css(&call.callee) => call .arguments @@ -2512,86 +2723,53 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { walk_expression(self, it); // Handle styled function calls - if !self.styled_imports.is_empty() { - 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 is_styled_name = |name: &str| self.styled_imports.contains(name); - - let is_styled = if let Some(tag_or_call) = tag_or_call.map(unwrap_syntax_only) { - if let Expression::StaticMemberExpression(member) = tag_or_call { - if let Expression::Identifier(ident) = &member.object { - is_styled_name(ident.name.as_str()) - } else { - false - } - } else if let Expression::CallExpression(call) = tag_or_call { - if let Expression::Identifier(ident) = &call.callee { - is_styled_name(ident.name.as_str()) - } else { - false - } - } else if let Expression::Identifier(ident) = 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. - is_styled_name(ident.name.as_str()) && argument_count == 2 - } else { - false - } + 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 inherited = extended(it) + .and_then(|base| self.style_values.symbol(base)) + .and_then(|symbol| self.styled_definitions.get(&symbol)) + .filter(|definition| definition.extendable()); + let start = it.span().start; + let marker = self + .pending_marker + .take_if(|(at, _)| *at == start) + .map(|(_, marker)| marker); + let StyledExtraction { + result, + expression, + errors, + definition, + } = extract_style_from_styled( + &self.ast, + it, + Naming { + split_filename: self.split_filename.as_deref(), + marker: marker.as_deref(), + }, + &self.imports, + &attrs, + inherited, + forward, + ); + self.errors.extend(errors); + self.styles.extend( + result + .styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract), + ); + *it = if definition.is_some() { + self.forwards_refs = true; + forward_ref(&self.ast, expression) } else { - false + expression }; - - 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 inherited = extended(it) - .and_then(|base| self.style_values.symbol(base)) - .and_then(|symbol| self.styled_definitions.get(&symbol)) - .filter(|definition| definition.extendable()); - let start = it.span().start; - let marker = self - .pending_marker - .take_if(|(at, _)| *at == start) - .map(|(_, marker)| marker); - let StyledExtraction { - result, - expression, - errors, - definition, - } = extract_style_from_styled( - &self.ast, - it, - Naming { - split_filename: self.split_filename.as_deref(), - marker: marker.as_deref(), - }, - &self.imports, - &attrs, - inherited, - forward, - ); - self.errors.extend(errors); - self.styles.extend( - result - .styles - .into_iter() - .flat_map(ExtractStyleProp::into_extract), - ); - *it = if definition.is_some() { - self.forwards_refs = true; - forward_ref(&self.ast, expression) - } else { - expression - }; - self.pending_styled = definition.map(|definition| (start, definition)); - } + self.pending_styled = definition.map(|definition| (start, definition)); } // `Component.withComponent(target)` on a styled component the file @@ -3031,6 +3209,14 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { } let offset = call.span.start; let is_css = matches!(util_type.as_ref(), UtilType::Css); + self.local_arguments( + match util_type.as_ref() { + UtilType::Css => "css", + UtilType::Keyframes => "keyframes", + UtilType::GlobalCss | UtilType::GlobalCssComponent => "globalCss", + }, + &call.arguments, + ); if is_css { self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); @@ -3665,6 +3851,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 .filter(|symbol| self.selected_components.contains(symbol)) .zip(start) From 734fd16fe6ea7c8f60edb07572b23829707e3696 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 3 Oct 2026 23:18:51 +0900 Subject: [PATCH 2/5] test(extractor): cover parenthesized function-local style objects Refs #690, #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/lib.rs | 1 + ...extractor__tests__style_objects_declared_in_functions.snap | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4a7500b22..6b7144ec2 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -21702,6 +21702,7 @@ export const App = () => ; }", "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}")) 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 index 6afe04078..ef8ad132e 100644 --- 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 @@ -83,4 +83,8 @@ expression: "library_outputs(&borrowed, SIZE_MODULES)" "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\" }", + ), ] From 6ba102f423ec5fcfa6471735865008f1adb68a19 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 18 Oct 2026 18:52:20 +0900 Subject: [PATCH 3/5] fix(extractor): preserve callback scope and local style guards Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_local_style_objects.json | 2 +- libs/extractor/src/consumed_references.rs | 81 +++++ libs/extractor/src/css_utils.rs | 39 ++- .../extractor/extract_style_from_styled.rs | 329 ++---------------- .../src/extractor/styled_arguments.rs | 170 +++++++++ libs/extractor/src/extractor/styled_props.rs | 129 +++++++ .../src/extractor/styled_props_leaves.rs | 116 ++++++ .../extractor/styled_props_self_reference.rs | 62 ++++ .../src/extractor/styled_props_values.rs | 92 +++++ .../src/extractor/styled_record_evaluation.rs | 30 +- .../src/extractor/styled_render_body_tests.rs | 232 ++++++++++++ libs/extractor/src/implicit_props_theme.rs | 235 +++++++++++++ .../src/implicit_props_theme_scope.rs | 94 +++++ .../src/implicit_props_theme_tests.rs | 147 ++++++++ libs/extractor/src/imported_constants.rs | 31 +- ...the_props_and_arrays_under_conditions.snap | 22 +- libs/extractor/src/styled_reads.rs | 27 +- .../styled_source_tests/css_prop_execution.rs | 18 + .../implicit_theme_scope.rs | 134 +++++++ .../local_member_accounting.rs | 193 ++++++++++ .../styled_source_tests/local_member_guard.rs | 96 +++++ .../props_function_bindings.rs | 176 ++++++++++ .../props_function_evaluation.rs | 216 ++++++++++++ .../props_function_order.rs | 169 +++++++++ .../props_function_self_reference.rs | 127 +++++++ .../props_function_theme.rs | 131 +++++++ 26 files changed, 2755 insertions(+), 343 deletions(-) create mode 100644 libs/extractor/src/consumed_references.rs create mode 100644 libs/extractor/src/extractor/styled_arguments.rs create mode 100644 libs/extractor/src/extractor/styled_props.rs create mode 100644 libs/extractor/src/extractor/styled_props_leaves.rs create mode 100644 libs/extractor/src/extractor/styled_props_self_reference.rs create mode 100644 libs/extractor/src/extractor/styled_props_values.rs create mode 100644 libs/extractor/src/extractor/styled_render_body_tests.rs create mode 100644 libs/extractor/src/implicit_props_theme.rs create mode 100644 libs/extractor/src/implicit_props_theme_scope.rs create mode 100644 libs/extractor/src/implicit_props_theme_tests.rs create mode 100644 libs/extractor/src/styled_source_tests/implicit_theme_scope.rs create mode 100644 libs/extractor/src/styled_source_tests/local_member_accounting.rs create mode 100644 libs/extractor/src/styled_source_tests/local_member_guard.rs create mode 100644 libs/extractor/src/styled_source_tests/props_function_bindings.rs create mode 100644 libs/extractor/src/styled_source_tests/props_function_evaluation.rs create mode 100644 libs/extractor/src/styled_source_tests/props_function_order.rs create mode 100644 libs/extractor/src/styled_source_tests/props_function_self_reference.rs create mode 100644 libs/extractor/src/styled_source_tests/props_function_theme.rs diff --git a/.changepacks/changepack_log_local_style_objects.json b/.changepacks/changepack_log_local_style_objects.json index 9c8fbc53d..87b6a1c47 100644 --- a/.changepacks/changepack_log_local_style_objects.json +++ b/.changepacks/changepack_log_local_style_objects.json @@ -2,6 +2,6 @@ "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, - "note": "A style object declared with const inside a function and passed to css(), styled(), globalCss(), keyframes(), a style prop or a spread (also through the Emotion, styled-components and vanilla-extract aliases) now compiles when every entry is written out and the object is only read as styles, instead of leaving the object where the class belongs; the build errors, at the binding, when the object is also read elsewhere (where code may change it), and at the call when it holds values only the runtime gives. Opaque function calls in Emotion's ClassNames cx remain located build errors because their result may carry Devup style classes; support is deferred until that can be ruled out. css() and styled() compose an array of rule objects under a condition. A style function of the props (styled.div((props) => ({ ... })), and as a later argument) compiles: each value reading the props becomes a CSS variable the component sets (a number is a px length unless the property is unitless), mapped theme reads (including theme.a[2]) become var(--a-2), unmapped theme reads retain the parent build error, and a condition chooses per value. New build errors: styled(...).attrs(...) chained after the styles (chain .attrs before them), a style function of the props that does not give one rule object at once with every entry written out, and a style object declared in a function that is also read elsewhere", + "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. 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/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 d09ee14a1..471d0f06b 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -21,6 +21,11 @@ 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; + +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, @@ -53,6 +58,13 @@ 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 { + 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()?) @@ -66,7 +78,20 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { let [param] = params.items.as_slice() else { return None; }; - let root = theme_root(¶m.pattern)?; + 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 = body; @@ -144,12 +169,15 @@ pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { } } } + if reads_implicit_props_theme(expr) { + return true; + } let params = match expr { Expression::ArrowFunctionExpression(arrow) => &arrow.params, Expression::FunctionExpression(function) => &function.params, _ => return false, }; - let [param] = params.items.as_slice() else { + let Some(param) = params.items.first() else { return false; }; let Some(root) = theme_root(¶m.pattern) else { @@ -159,6 +187,7 @@ pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { return false; } let mut reads = ThemeReads { root, found: false }; + reads.visit_formal_parameters(params); match expr { Expression::ArrowFunctionExpression(arrow) => match &arrow.body { oxc_ast::ast::ArrowFunctionBody::FunctionBody(body) => reads.visit_function_body(body), @@ -203,7 +232,11 @@ fn theme_root<'p>(pattern: &'p BindingPattern<'_>) -> Option> { Some(ThemeRoot::Props(identifier.name.as_str())) } BindingPattern::ObjectPattern(object) => object.properties.iter().find_map(|property| { - let BindingPattern::BindingIdentifier(identifier) = &property.value else { + 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) diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 789370867..e958e5c7d 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -10,19 +10,16 @@ use crate::{ extractor::{ ExtractResult, extract_style_from_expression::{LiteralHandling, extract_style_from_expression}, - rule_array_evaluation::{self, composes_array_under_condition}, styled_record::prepare, - styled_record_evaluation::{Evaluations, bind_render, visit_style_expressions}, - styled_rule_arrays::distribute_conditions, + 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, 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, 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}; @@ -38,203 +35,15 @@ use oxc_ast_visit::VisitMut; use oxc_span::{GetSpan, SPAN}; use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; -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 })`"; +#[path = "styled_props.rs"] +mod props; -/// The rules `style`, an argument of a styled component, gives, with each -/// value a function of the props written as the value of a CSS variable the -/// component sets: a function giving rules becomes the rules with each value -/// read from the props as such a function, so a condition chooses per value. -/// `Err` holds the located error for rules the build cannot read so. -fn rules_reading_props<'a>( - ast_builder: &AstBuilder<'a>, - style: &mut Expression<'a>, -) -> Result<(), (u32, String)> { - use oxc_allocator::TakeIn; - - if matches!( - unwrap_syntax_only(style), - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) - ) { - let code = style.clone_in(ast_builder.allocator()); - let invalid = || { - ( - code.span().start, - build_time_error("styled", &readable_code(&code), PROPS_FUNCTION), - ) - }; - let (params, returned) = crate::css_prop::render_function(style).ok_or_else(invalid)?; - let params = params.clone_in_with_semantic_ids(ast_builder.allocator()); - let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(returned) else { - // Class callbacks retain style_arguments' original composition refusal. - return Ok(()); - }; - props_leaves(ast_builder, object, ¶ms).map_err(|error| match error { - PropsRulesError::Rules => invalid(), - PropsRulesError::Theme(error) => error, - })?; - *style = returned.take_in(ast_builder); - } - if let Expression::ObjectExpression(object) = unwrap_syntax_only_mut(style) { - call_with_props(ast_builder, object)?; - } - Ok(()) -} - -/// Whether `value` is a literal, which reads the same given any props -fn is_written_out(value: &Expression<'_>) -> bool { - let value = unwrap_syntax_only(value); - crate::utils::get_string_by_literal_expression(value).is_some() - || crate::utils::js_number_literal(value).is_some() - || matches!( - value, - Expression::BooleanLiteral(_) | Expression::NullLiteral(_) - ) -} +#[path = "styled_arguments.rs"] +mod arguments; -/// Why a function's rule object cannot be lowered to props-reading leaves. -enum PropsRulesError { - Rules, - Theme((u32, String)), -} - -/// The values of `object` that read the props, each as a function taking the -/// parameters `params` of the function that gave the rules -fn props_leaves<'a>( - ast_builder: &AstBuilder<'a>, - object: &mut oxc_ast::ast::ObjectExpression<'a>, - params: &FormalParameters<'a>, -) -> Result<(), PropsRulesError> { - use oxc_allocator::TakeIn; - - for property in &mut object.properties { - let ObjectPropertyKind::ObjectProperty(property) = property else { - return Err(PropsRulesError::Rules); - }; - if property.computed || property.method || property.kind != oxc_ast::ast::PropertyKind::Init - { - return Err(PropsRulesError::Rules); - } - let key = - crate::utils::get_str_by_property_key(&property.key).ok_or(PropsRulesError::Rules)?; - match unwrap_syntax_only_mut(&mut property.value) { - Expression::ObjectExpression(inner) => props_leaves(ast_builder, inner, params)?, - Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_) => {} - value if is_written_out(value) => {} - _ => { - let leaf = property.value.take_in(ast_builder); - property.value = props_function(ast_builder, params, leaf, &key) - .map_err(PropsRulesError::Theme)?; - } - } - } - Ok(()) -} - -/// `(params) => leaf`: a number is a `px` length, as Emotion reads it, unless -/// the property takes numbers as they are or the function reads the theme -fn props_function<'a>( - ast_builder: &AstBuilder<'a>, - params: &FormalParameters<'a>, - leaf: Expression<'a>, - key: &str, -) -> Result, (u32, String)> { - let span = leaf.span(); - let function = |body: Expression<'a>| { - Expression::new_arrow_function_expression( - span, - false, - None::>>, - oxc_allocator::Box::new_in( - params.clone_in_with_semantic_ids(ast_builder.allocator()), - ast_builder, - ), - None::>>, - body.into(), - ast_builder, - ) - }; - let plain = function(leaf.clone_in_with_semantic_ids(ast_builder.allocator())); - if crate::css_utils::reads_unmapped_theme(&plain) { - return Err(crate::css_utils::unmapped_theme_error("styled", &plain)); - } - if crate::utils::keeps_bare_number(key) - || crate::css_utils::theme_var_reference(&plain).is_some() - { - return Ok(plain); - } - let value = || identifier(ast_builder, "__devupValue"); - let is_number = Expression::new_binary_expression( - SPAN, - Expression::new_unary_expression(SPAN, UnaryOperator::Typeof, value(), ast_builder), - BinaryOperator::StrictEquality, - Expression::new_string_literal(SPAN, "number", None, ast_builder), - ast_builder, - ); - let length = Expression::new_binary_expression( - SPAN, - value(), - BinaryOperator::Addition, - Expression::new_string_literal(SPAN, "px", None, ast_builder), - ast_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, - Expression::new_numeric_literal( - SPAN, - 0.0, - None, - oxc_syntax::number::NumberBase::Decimal, - ast_builder, - ), - ast_builder, - ), - ast_builder, - ), - length, - value(), - ast_builder, - ); - let length = named_arrow(ast_builder, "__devupValue", length); - Ok(function(wrap_direct_call(ast_builder, &length, &[leaf]))) -} - -/// The values of `object` that are functions of the props written as the -/// result of calling them with the props the component renders, which the -/// styles read as the values of CSS variables; a function reading the theme -/// stays, as the styles read it as a variable of the theme -fn call_with_props<'a>( - ast_builder: &AstBuilder<'a>, - object: &mut oxc_ast::ast::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(ast_builder, inner)?, - function @ (Expression::ArrowFunctionExpression(_) - | Expression::FunctionExpression(_)) - if crate::css_utils::theme_var_reference(function).is_none() => - { - if crate::css_utils::reads_unmapped_theme(function) { - return Err(crate::css_utils::unmapped_theme_error("styled", function)); - } - *function = - wrap_direct_call(ast_builder, function, &[identifier(ast_builder, "rest")]); - } - _ => {} - } - } - Ok(()) -} +#[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 @@ -923,108 +732,18 @@ pub fn extract_style_from_styled<'a>( if let Expression::CallExpression(call) = expression && extract_base_tag_and_class_name(ast_builder, &call.callee, imports).is_some() { - for argument in &mut call.arguments { - if let Some(rules) = argument.as_expression_mut() - && let Err(error) = rules_reading_props(ast_builder, rules) - { - errors.push(error); - *rules = Expression::new_object_expression( - SPAN, - oxc_allocator::Vec::new_in(ast_builder), - ast_builder, - ); - } - } - let mut distributed = call - .arguments - .clone_in_with_semantic_ids(ast_builder.allocator()); - for argument in &mut distributed { - if let Some(part) = argument.as_expression_mut() { - distribute_conditions(ast_builder, part, refused_array_controllers); - } - } - if call.arguments.iter().any(|argument| { - argument - .as_expression() - .is_some_and(composes_array_under_condition) - }) && (reads_directly(&distributed) - || style_arguments(ast_builder, &distributed).is_some()) - { - let source = Expression::new_array_expression( - SPAN, - oxc_allocator::Vec::from_iter_in( - call.arguments.iter().map(|argument| { - let value = match argument { - Argument::SpreadElement(spread) => &spread.argument, - argument => argument.to_expression(), - }; - value - .clone_in_with_semantic_ids(ast_builder.allocator()) - .into() - }), - ast_builder, - ), - ast_builder, - ); - let mut evaluations = Evaluations::new(ast_builder, readable_code(&original_scope)); - if let Some(prepared @ Expression::ArrayExpression(_)) = - rule_array_evaluation::prepare(&mut evaluations, &source, |_| false) - { - for argument in &call.arguments { - if let Some(value) = argument.as_expression() { - reads.read_in(value); - } - } - original_scope = Expression::new_array_expression( - SPAN, - oxc_allocator::Vec::from_array_in( - [ - original_scope.into(), - prepared.clone_in(ast_builder.allocator()).into(), - ], - ast_builder, - ), - ast_builder, - ); - if let Expression::ArrayExpression(array) = prepared { - call.arguments = oxc_allocator::Vec::from_iter_in( - array.elements.iter().filter_map(|element| { - element.as_expression().map(|value| { - Argument::from( - value.clone_in_with_semantic_ids(ast_builder.allocator()), - ) - }) - }), - ast_builder, - ); - } - captured_bindings = evaluations.bindings; - } - } - for argument in &mut call.arguments { - if let Some(part) = argument.as_expression_mut() { - distribute_conditions(ast_builder, part, refused_array_controllers); - } - } - 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 @@ -2063,7 +1782,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/styled_arguments.rs b/libs/extractor/src/extractor/styled_arguments.rs new file mode 100644 index 000000000..ed7c2ab89 --- /dev/null +++ b/libs/extractor/src/extractor/styled_arguments.rs @@ -0,0 +1,170 @@ +//! 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)>, +} + +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()); + for argument in &mut distributed { + if let Some(part) = argument.as_expression_mut() { + distribute_conditions(builder, part, 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 prepared = if callbacks { + let mut prepared = arguments.clone_in_with_semantic_ids(builder.allocator()); + 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) => &mut spread.argument, + argument => argument.to_expression_mut(), + }; + let start = evaluations.bindings.len(); + match rule_array_evaluation::prepare(&mut evaluations, value, |_| false) { + Some(value_read) => *value = value_read, + None => evaluations.bindings.truncate(start), + } + } + } + Some(prepared) + } else { + let elements = arguments.iter().map(|argument| { + let value = match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }; + value.clone_in_with_semantic_ids(builder.allocator()).into() + }); + let source = Expression::new_array_expression( + SPAN, + oxc_allocator::Vec::from_iter_in(elements, builder), + builder, + ); + match rule_array_evaluation::prepare(&mut evaluations, &source, |_| false) { + Some(Expression::ArrayExpression(array)) => Some(oxc_allocator::Vec::from_iter_in( + array.elements.iter().filter_map(|element| { + element.as_expression().map(|value| { + Argument::from(value.clone_in_with_semantic_ids(builder.allocator())) + }) + }), + builder, + )), + _ => None, + } + }; + if let Some(prepared) = prepared { + *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; + } + } + for argument in &mut *arguments { + if let Some(part) = argument.as_expression_mut() { + distribute_conditions(builder, part, 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 +} 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..d05524c2a --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_leaves.rs @@ -0,0 +1,116 @@ +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()), + ); + if reads_unmapped_theme(&plain) { + return Err(PropsRulesError::Theme(unmapped_theme_error( + "styled", &plain, + ))); + } + if function && reads_unmapped_theme(unwrap_syntax_only(value)) { + return Err(PropsRulesError::Theme(unmapped_theme_error( + "styled", value, + ))); + } + 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..d456a1ce6 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_self_reference.rs @@ -0,0 +1,62 @@ +//! 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 name = function.id.as_ref()?.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 body = oxc_allocator::Vec::from_array_in( + [Statement::ExpressionStatement(ExpressionStatement::boxed( + SPAN, + style.clone_in(&allocator), + &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 Statement::ExpressionStatement(statement) = program.body.first()? else { + return None; + }; + let Expression::FunctionExpression(function) = unwrap_syntax_only(&statement.expression) else { + return None; + }; + let symbol = function.id.as_ref()?.symbol_id.get()?; + let scoping = semantic.scoping(); + 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"), + ), + ) + }) +} 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..377e5ebe5 --- /dev/null +++ b/libs/extractor/src/extractor/styled_props_values.rs @@ -0,0 +1,92 @@ +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() + || crate::utils::js_number_literal(value).is_some() + || matches!( + value, + Expression::BooleanLiteral(_) | 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 c5363cde3..52090a774 100644 --- a/libs/extractor/src/extractor/styled_record_evaluation.rs +++ b/libs/extractor/src/extractor/styled_record_evaluation.rs @@ -1,5 +1,8 @@ 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; @@ -147,18 +150,18 @@ pub(super) fn visit_style_expressions<'a>( } } -pub(crate) 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); @@ -199,6 +202,19 @@ pub(crate) 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, @@ -211,7 +227,7 @@ pub(crate) 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_render_body_tests.rs b/libs/extractor/src/extractor/styled_render_body_tests.rs new file mode 100644 index 000000000..00375a6f8 --- /dev/null +++ b/libs/extractor/src/extractor/styled_render_body_tests.rs @@ -0,0 +1,232 @@ +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 parsed = Parser::new(builder.allocator(), source, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("render fixture must be an expression"); + }; + unwrap_syntax_only(&statement.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: &[], + css_slot: None, + 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: &[], + css_slot: None, + 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: &[], + css_slot: None, + 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/implicit_props_theme.rs b/libs/extractor/src/implicit_props_theme.rs new file mode 100644 index 000000000..decc022a5 --- /dev/null +++ b/libs/extractor/src/implicit_props_theme.rs @@ -0,0 +1,235 @@ +//! 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 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 (params, arguments) = match unwrap_syntax_only(expr) { + Expression::FunctionExpression(function) => (&function.params, true), + Expression::ArrowFunctionExpression(arrow) => (&arrow.params, false), + _ => return false, + }; + 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 unwrap_syntax_only(expr) { + Expression::FunctionExpression(function) => { + if let Some(body) = &function.body { + reads.visit_root_body(body); + } + } + Expression::ArrowFunctionExpression(arrow) => match &arrow.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..003913930 --- /dev/null +++ b/libs/extractor/src/implicit_props_theme_scope.rs @@ -0,0 +1,94 @@ +//! Binding ownership for the implicit callback-props detector. + +use oxc_ast::ast::{ + ArrowFunctionExpression, 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_arrow_function_expression(&mut self, _: &ArrowFunctionExpression<'a>) {} + 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..a26f697cb --- /dev/null +++ b/libs/extractor/src/implicit_props_theme_tests.rs @@ -0,0 +1,147 @@ +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::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/imported_constants.rs b/libs/extractor/src/imported_constants.rs index f585207b9..2199619b0 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -30,6 +30,10 @@ 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; + #[derive(Clone, Debug)] enum Constant { String(String), @@ -610,12 +614,12 @@ pub(crate) fn inline_constants<'a>( px: false, position: InlinePosition::Rules, marks: px, - replaced: FxHashMap::default(), + replaced: ConsumedReferences::new(&scoping), class_names: Vec::new(), }; inline.visit_program(program); for (symbol, local) in declared { - let read = inline.replaced.get(&symbol).copied().unwrap_or(0); + let read = inline.replaced.count(symbol); if read > 0 && read < scoping.get_resolved_reference_ids(symbol).len() { inlined .errors @@ -1931,9 +1935,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, - /// How often a style read each binding below the top level, by what it - /// held - replaced: FxHashMap, + /// Semantic references erased by style folding or constant selection. + replaced: ConsumedReferences<'s>, /// The names the `` child functions around take `css` and /// `cx` by class_names: Vec, @@ -2033,15 +2036,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) }) } @@ -2055,8 +2063,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) }) } @@ -2225,12 +2235,7 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { { let span = expression.span(); let marked = self.marks.contains(&(span.start, span.end)); - if let Expression::Identifier(identifier) = &*expression - && let Some(reference) = identifier.reference_id.get() - && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() - { - *self.replaced.entry(symbol).or_default() += 1; - } + self.replaced.consume(expression); *expression = literal; if marked { px_number(self.ast_builder, expression); 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 index c98d030b3..9d5c846b8 100644 --- 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 @@ -5,19 +5,19 @@ expression: "library_outputs(&[\"import styled from '@emotion/styled';\\nexport [ ( "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: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.c))(rest)\", 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)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--c\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.c))(rest) },\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`])\", 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\\treturn __devupDom)(rest)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{ \\\"--c\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`]) },\\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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b c\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "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)} as={forwardedAs} className={[\\\"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: \"(()=>`x`)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", 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)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (()=>`x`)(rest),\\n\\t\\t\\\"--d\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(0,__devupRecord0[`0`])(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`1`])\", 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\\treturn __devupDom)(rest)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--b\\\": (0,__devupRecord0[`0`])(rest),\\n\\t\\t\\t\\\"--d\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`1`])\\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: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.color))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"line-height\", level: 0, identifier: \"(p=>p.lh)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(p=>p.w)(rest)\", 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)} as={forwardedAs} className={[\\\"a c d f\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.color))(rest),\\n\\t\\t\\\"--e\\\": (p=>p.lh)(rest),\\n\\t\\t\\\"--g\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\nexport const B = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \\\"div\\\", forwardedAs, ...rest }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"h\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--i\\\": (p=>p.w)(rest) },\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`])\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"line-height\", level: 0, identifier: \"__devupRecord0[`1`]\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`2`])\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(p=>p.w)(rest)\", 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\\treturn __devupDom)(rest)} as={forwardedAs} className={[\\\"a c d f\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--b\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`]),\\n\\t\\t\\t\\\"--e\\\": __devupRecord0[`1`],\\n\\t\\t\\t\\\"--g\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`2`])\\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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"h\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--i\\\": (p=>p.w)(rest) },\\n\\t...style\\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 }; });", @@ -41,11 +41,11 @@ expression: "library_outputs(&[\"import styled from '@emotion/styled';\\nexport ), ( "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\\treturn __devupDom)(rest)} as={forwardedAs} className={[`${__devupRecord0 ? \\\"a\\\" : \\\"\\\"} ${__devupRecord0 ? __devupRecord0 ? \\\"b\\\" : \\\"\\\" : \\\"c\\\"} ${__devupRecord1 ? \\\"d\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />;\\n})())({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\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 }), 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\\treturn __devupDom)(rest)} as={forwardedAs} className={[`${__devupRecord0 ? \\\"a\\\" : \\\"\\\"} ${__devupRecord0 ? __devupRecord0 ? \\\"b\\\" : \\\"\\\" : \\\"c\\\"} ${__devupRecord1 ? \\\"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\\treturn __devupDom)(rest)} as={forwardedAs} className={[`${__devupRecord0 ? \\\"a\\\" : \\\"\\\"} ${__devupRecord1 ? \\\"b\\\" : \\\"\\\"}`, className].filter(Boolean).join(\\\" \\\")} style={style} />;\\n})())({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\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\\\";\\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\\treturn __devupDom)(rest)} as={forwardedAs} className={[`${__devupRecord0 ? \\\"a\\\" : \\\"\\\"} ${__devupRecord1 ? \\\"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' }]);", @@ -57,7 +57,7 @@ expression: "library_outputs(&[\"import styled from '@emotion/styled';\\nexport ), ( "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: \"(p=>p.color)(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>p.h)(rest)\", 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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>p.color)(rest),\\n\\t\\t\\\"--e\\\": (p=>p.h)(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", 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: \"color\", level: 0, identifier: \"(p=>p.h)(rest)\", 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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a c d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>p.color)(rest),\\n\\t\\t\\\"--e\\\": (p=>p.h)(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({ color: ({ theme }) => theme.colors.primary, margin: ({ theme }) => theme.space[2] });", @@ -81,11 +81,11 @@ expression: "library_outputs(&[\"import styled from '@emotion/styled';\\nexport ), ( "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: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest)\", 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)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{ \\\"--b\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest) },\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"padding\", value: \"8px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`])\", 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\\treturn __devupDom)(rest)} as={forwardedAs} className={[\\\"a c\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{ \\\"--b\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`]) },\\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: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.w))(rest)\", 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)} as={forwardedAs} className={[\\\"a b d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--c\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.color))(rest),\\n\\t\\t\\\"--e\\\": (props=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(props.w))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "ToBTreeSet { styles: {Static(ExtractStaticStyle { property: \"margin\", value: \"1px\", level: 0, selector: None, style_order: None, layer: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`])\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"width\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`1`])\", 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\\treturn __devupDom)(rest)} as={forwardedAs} className={[\\\"a b d\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--c\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`]),\\n\\t\\t\\t\\\"--e\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`1`])\\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}`;", @@ -93,10 +93,10 @@ expression: "library_outputs(&[\"import styled from '@emotion/styled';\\nexport ), ( "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: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.h))(rest)\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.on?`red`:`blue`))(rest)\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest)\", 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)} as={forwardedAs} className={[\\\"a c e\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t...{\\n\\t\\t\\\"--b\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.on?`red`:`blue`))(rest),\\n\\t\\t\\\"--d\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.m))(rest),\\n\\t\\t\\\"--f\\\": (p=>(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(p.h))(rest)\\n\\t},\\n\\t...style\\n}} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "ToBTreeSet { styles: {Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`])\", selector: None, style_order: None }), Dynamic(ExtractDynamicStyle { property: \"color\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`2`])\", selector: Some(Selector(\"&:hover\")), style_order: None }), Dynamic(ExtractDynamicStyle { property: \"margin\", level: 0, identifier: \"(__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`1`])\", 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\\treturn __devupDom)(rest)} as={forwardedAs} className={[\\\"a c e\\\", className].filter(Boolean).join(\\\" \\\")} style={{\\n\\t\\t...{\\n\\t\\t\\t\\\"--b\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`0`]),\\n\\t\\t\\t\\\"--d\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`1`]),\\n\\t\\t\\t\\\"--f\\\": (__devupValue=>typeof __devupValue===`number`&&__devupValue!==0?__devupValue+`px`:__devupValue)(__devupRecord0[`2`])\\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 }) => __devupDom)(rest)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />)({\\n\\t...__devupRefProps,\\n\\tref: __devupRef\\n}));\\n\" }", + "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)} as={forwardedAs} className={[\\\"a b\\\", className].filter(Boolean).join(\\\" \\\")} style={style} />;\\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/css_prop_execution.rs b/libs/extractor/src/styled_source_tests/css_prop_execution.rs index 149c222c4..a7674f6cd 100644 --- a/libs/extractor/src/styled_source_tests/css_prop_execution.rs +++ b/libs/extractor/src/styled_source_tests/css_prop_execution.rs @@ -193,3 +193,21 @@ fn conditional_rule_arrays_keep_one_getter_read_and_consistent_branches( #[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; 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/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..b23850aa0 --- /dev/null +++ b/libs/extractor/src/styled_source_tests/props_function_bindings.rs @@ -0,0 +1,176 @@ +use super::execute_in_mode; +use rstest::rstest; +use serial_test::serial; + +#[path = "props_function_self_reference.rs"] +mod 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_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] + ]) + ); +} From a2cbceaa98fcd75b80b9a25cab6b2cd9632a67e9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Oct 2026 03:15:52 +0900 Subject: [PATCH 4/5] fix(extractor): cover callback preparation and remove redundant analysis Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_w62m_745_repair.json | 7 + libs/extractor/src/css_utils.rs | 19 +-- .../src/extractor/rule_array_evaluation.rs | 4 + .../extractor/rule_array_evaluation_tests.rs | 124 ++++++++++++++++ .../src/extractor/styled_arguments.rs | 69 ++++----- .../src/extractor/styled_arguments_tests.rs | 137 ++++++++++++++++++ .../extractor/styled_props_self_reference.rs | 13 +- .../styled_record_evaluation_tests.rs | 23 +++ .../src/extractor/styled_render_body_tests.rs | 14 +- libs/extractor/src/implicit_props_theme.rs | 17 +-- .../src/implicit_props_theme_scope.rs | 5 +- .../src/implicit_props_theme_tests.rs | 4 + libs/extractor/src/theme_callback.rs | 29 ++++ 13 files changed, 390 insertions(+), 75 deletions(-) create mode 100644 .changepacks/changepack_log_w62m_745_repair.json create mode 100644 libs/extractor/src/extractor/rule_array_evaluation_tests.rs create mode 100644 libs/extractor/src/extractor/styled_arguments_tests.rs create mode 100644 libs/extractor/src/theme_callback.rs 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/css_utils.rs b/libs/extractor/src/css_utils.rs index adccee18e..50bb437f3 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -28,6 +28,9 @@ 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::{ @@ -176,10 +179,9 @@ pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { if reads_implicit_props_theme(expr) { return true; } - let params = match expr { - Expression::ArrowFunctionExpression(arrow) => &arrow.params, - Expression::FunctionExpression(function) => &function.params, - _ => return false, + let Some(theme_callback::Callback { params, body }) = theme_callback::Callback::read(expr) + else { + return false; }; let Some(param) = params.items.first() else { return false; @@ -192,8 +194,8 @@ pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { } let mut reads = ThemeReads { root, found: false }; reads.visit_formal_parameters(params); - match expr { - Expression::ArrowFunctionExpression(arrow) => match &arrow.body { + 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() { @@ -201,12 +203,11 @@ pub(crate) fn reads_unmapped_theme(expr: &Expression<'_>) -> bool { } } }, - Expression::FunctionExpression(function) => { - if let Some(body) = &function.body { + theme_callback::Body::Function(body) => { + if let Some(body) = body { reads.visit_function_body(body); } } - _ => {} } reads.found } diff --git a/libs/extractor/src/extractor/rule_array_evaluation.rs b/libs/extractor/src/extractor/rule_array_evaluation.rs index be9bee5f5..234890511 100644 --- a/libs/extractor/src/extractor/rule_array_evaluation.rs +++ b/libs/extractor/src/extractor/rule_array_evaluation.rs @@ -34,6 +34,10 @@ pub(crate) fn composes_array_under_condition(expression: &Expression<'_>) -> boo } } +#[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>( 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 index ed7c2ab89..41164a18a 100644 --- a/libs/extractor/src/extractor/styled_arguments.rs +++ b/libs/extractor/src/extractor/styled_arguments.rs @@ -82,50 +82,35 @@ pub(super) fn prepare<'a>( .is_some_and(rule_array_evaluation::composes_array_under_condition) }); if admitted && capture { - let prepared = if callbacks { - let mut prepared = arguments.clone_in_with_semantic_ids(builder.allocator()); - 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) => &mut spread.argument, - argument => argument.to_expression_mut(), - }; - let start = evaluations.bindings.len(); - match rule_array_evaluation::prepare(&mut evaluations, value, |_| false) { - Some(value_read) => *value = value_read, - None => evaluations.bindings.truncate(start), - } - } - } - Some(prepared) - } else { - let elements = arguments.iter().map(|argument| { - let value = match argument { + 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(), }; - value.clone_in_with_semantic_ids(builder.allocator()).into() - }); - let source = Expression::new_array_expression( - SPAN, - oxc_allocator::Vec::from_iter_in(elements, builder), - builder, - ); - match rule_array_evaluation::prepare(&mut evaluations, &source, |_| false) { - Some(Expression::ArrayExpression(array)) => Some(oxc_allocator::Vec::from_iter_in( - array.elements.iter().filter_map(|element| { - element.as_expression().map(|value| { - Argument::from(value.clone_in_with_semantic_ids(builder.allocator())) - }) - }), - builder, - )), - _ => None, + 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 let Some(prepared) = prepared { + } + if complete { *arguments = prepared; let names = evaluations.bindings.iter().map(|(name, _)| { Expression::new_identifier( @@ -168,3 +153,7 @@ pub(super) fn prepare<'a>( } 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_self_reference.rs b/libs/extractor/src/extractor/styled_props_self_reference.rs index d456a1ce6..6676f3fe1 100644 --- a/libs/extractor/src/extractor/styled_props_self_reference.rs +++ b/libs/extractor/src/extractor/styled_props_self_reference.rs @@ -14,7 +14,8 @@ pub(super) fn error(style: &Expression<'_>) -> Option<(u32, String)> { let Expression::FunctionExpression(function) = unwrap_syntax_only(style) else { return None; }; - let name = function.id.as_ref()?.name.as_str(); + 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); @@ -37,14 +38,10 @@ pub(super) fn error(style: &Expression<'_>) -> Option<(u32, String)> { &builder, ); let semantic = SemanticBuilder::new().build(&program).semantic; - let Statement::ExpressionStatement(statement) = program.body.first()? else { - return None; - }; - let Expression::FunctionExpression(function) = unwrap_syntax_only(&statement.expression) else { - return None; - }; - let symbol = function.id.as_ref()?.symbol_id.get()?; let scoping = semantic.scoping(); + let symbol = scoping.symbol_ids().find(|symbol| { + scoping.symbol_span(*symbol) == binding.span && scoping.symbol_name(*symbol) == name + })?; scoping .get_resolved_reference_ids(symbol) .iter() 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 index e952a1e5d..f27de23a6 100644 --- a/libs/extractor/src/extractor/styled_render_body_tests.rs +++ b/libs/extractor/src/extractor/styled_render_body_tests.rs @@ -8,12 +8,14 @@ use rstest::rstest; use super::*; fn expression<'a>(builder: &AstBuilder<'a>, source: &str) -> Expression<'a> { - let parsed = Parser::new(builder.allocator(), source, SourceType::mjs()).parse(); - assert_eq!(parsed.diagnostics.len(), 0); - let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { - panic!("render fixture must be an expression"); - }; - unwrap_syntax_only(&statement.expression).clone_in(builder.allocator()) + 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>( diff --git a/libs/extractor/src/implicit_props_theme.rs b/libs/extractor/src/implicit_props_theme.rs index decc022a5..567278ec6 100644 --- a/libs/extractor/src/implicit_props_theme.rs +++ b/libs/extractor/src/implicit_props_theme.rs @@ -13,6 +13,7 @@ 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)] @@ -21,11 +22,10 @@ mod tests; /// These roots deliberately do not participate in static theme-path mapping. pub(crate) fn reads(expr: &Expression<'_>) -> bool { - let (params, arguments) = match unwrap_syntax_only(expr) { - Expression::FunctionExpression(function) => (&function.params, true), - Expression::ArrowFunctionExpression(arrow) => (&arrow.params, false), - _ => return false, + 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 @@ -46,17 +46,16 @@ pub(crate) fn reads(expr: &Expression<'_>) -> bool { bindings.visit_formal_parameters(params); reads.roots.arguments &= !bindings.arguments; reads.visit_formal_parameters(params); - match unwrap_syntax_only(expr) { - Expression::FunctionExpression(function) => { - if let Some(body) = &function.body { + match body { + Body::Function(body) => { + if let Some(body) = body { reads.visit_root_body(body); } } - Expression::ArrowFunctionExpression(arrow) => match &arrow.body { + Body::Arrow(body) => match body { ArrowFunctionBody::FunctionBody(body) => reads.visit_root_body(body), body => reads.visit_arrow_function_body(body), }, - _ => {} } reads.found } diff --git a/libs/extractor/src/implicit_props_theme_scope.rs b/libs/extractor/src/implicit_props_theme_scope.rs index 003913930..92d927c9b 100644 --- a/libs/extractor/src/implicit_props_theme_scope.rs +++ b/libs/extractor/src/implicit_props_theme_scope.rs @@ -1,8 +1,8 @@ //! Binding ownership for the implicit callback-props detector. use oxc_ast::ast::{ - ArrowFunctionExpression, BindingIdentifier, Class, Expression, Function, Statement, - VariableDeclaration, VariableDeclarationKind, + BindingIdentifier, Class, Expression, Function, Statement, VariableDeclaration, + VariableDeclarationKind, }; use oxc_ast_visit::Visit; use oxc_syntax::scope::ScopeFlags; @@ -89,6 +89,5 @@ impl<'a> Visit<'a> for FunctionVars<'_> { fn visit_expression(&mut self, _: &Expression<'a>) {} fn visit_function(&mut self, _: &Function<'a>, _: ScopeFlags) {} - fn visit_arrow_function_expression(&mut self, _: &ArrowFunctionExpression<'a>) {} 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 index a26f697cb..f8e299141 100644 --- a/libs/extractor/src/implicit_props_theme_tests.rs +++ b/libs/extractor/src/implicit_props_theme_tests.rs @@ -6,6 +6,10 @@ 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)] 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, + } + } +} From 58de84303cf30fc5023d59a50734db6e8df1f5e4 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Oct 2026 12:10:53 +0900 Subject: [PATCH 5/5] fix(extractor): guard generic self references and trim preparation Refs #688, #690, #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_local_style_objects.json | 2 +- .../src/extractor/rule_array_evaluation.rs | 40 ++++----- .../src/extractor/styled_arguments.rs | 24 ++--- .../src/extractor/styled_props_leaves.rs | 16 ++-- .../extractor/styled_props_self_reference.rs | 15 +++- .../styled_props_self_reference_tests.rs | 30 +++++++ .../src/extractor/styled_props_values.rs | 6 +- .../props_function_bindings.rs | 3 + .../props_function_generic_self_reference.rs | 90 +++++++++++++++++++ libs/extractor/src/visit.rs | 4 +- 10 files changed, 177 insertions(+), 53 deletions(-) create mode 100644 libs/extractor/src/extractor/styled_props_self_reference_tests.rs create mode 100644 libs/extractor/src/styled_source_tests/props_function_generic_self_reference.rs diff --git a/.changepacks/changepack_log_local_style_objects.json b/.changepacks/changepack_log_local_style_objects.json index 87b6a1c47..88d69c9e5 100644 --- a/.changepacks/changepack_log_local_style_objects.json +++ b/.changepacks/changepack_log_local_style_objects.json @@ -2,6 +2,6 @@ "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. 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.", + "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/libs/extractor/src/extractor/rule_array_evaluation.rs b/libs/extractor/src/extractor/rule_array_evaluation.rs index 234890511..5badabd41 100644 --- a/libs/extractor/src/extractor/rule_array_evaluation.rs +++ b/libs/extractor/src/extractor/rule_array_evaluation.rs @@ -12,26 +12,22 @@ use crate::utils::{ /// Arrays here compose rules, rather than selecting responsive property values. pub(crate) fn composes_array_under_condition(expression: &Expression<'_>) -> bool { - match unwrap_syntax_only(expression) { - Expression::ArrayExpression(array) => array.elements.iter().any(|element| { - element - .as_expression() - .is_some_and(composes_array_under_condition) - }), - Expression::ConditionalExpression(choice) => [&choice.consequent, &choice.alternate] - .into_iter() - .any(|side| { - matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) - || composes_array_under_condition(side) - }), - Expression::LogicalExpression(logical) => { - [&logical.left, &logical.right].into_iter().any(|side| { - matches!(unwrap_syntax_only(side), Expression::ArrayExpression(_)) - || composes_array_under_condition(side) - }) + 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) + }); } - _ => false, - } + 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)] @@ -108,9 +104,7 @@ impl<'a, K: Fn(&Expression<'a>) -> bool> Preparation<'_, '_, 'a, K> { self.value(&mut logical.left, guard)?; self.reads.boolean(logical.operator == LogicalOperator::And) } else { - if !(self.known)(unwrap_syntax_only(&logical.left)) { - self.capture(&mut logical.left, guard); - } + self.capture(&mut logical.left, guard); match logical.operator { LogicalOperator::And => logical.left.clone_in(builder.allocator()), LogicalOperator::Or => self.reads.choose( @@ -181,7 +175,7 @@ impl<'a, K: Fn(&Expression<'a>) -> bool> Preparation<'_, '_, 'a, K> { | Expression::ClassExpression(_) => {} Expression::Identifier(identifier) if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") => {} - _ => self.capture(value, guard), + _ => *value = self.reads.capture(value, guard), } Some(()) } diff --git a/libs/extractor/src/extractor/styled_arguments.rs b/libs/extractor/src/extractor/styled_arguments.rs index 41164a18a..9582a5e2e 100644 --- a/libs/extractor/src/extractor/styled_arguments.rs +++ b/libs/extractor/src/extractor/styled_arguments.rs @@ -29,6 +29,18 @@ pub(super) struct Prepared<'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>, @@ -68,11 +80,7 @@ pub(super) fn prepare<'a>( plans.push(plan); } let mut distributed = arguments.clone_in_with_semantic_ids(builder.allocator()); - for argument in &mut distributed { - if let Some(part) = argument.as_expression_mut() { - distribute_conditions(builder, part, refused); - } - } + 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 @@ -126,11 +134,7 @@ pub(super) fn prepare<'a>( result.bindings = evaluations.bindings; } } - for argument in &mut *arguments { - if let Some(part) = argument.as_expression_mut() { - distribute_conditions(builder, part, refused); - } - } + 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); diff --git a/libs/extractor/src/extractor/styled_props_leaves.rs b/libs/extractor/src/extractor/styled_props_leaves.rs index d05524c2a..292b3e8a5 100644 --- a/libs/extractor/src/extractor/styled_props_leaves.rs +++ b/libs/extractor/src/extractor/styled_props_leaves.rs @@ -74,14 +74,16 @@ impl<'a> Leaves<'_, '_, 'a> { self.params, value.clone_in_with_semantic_ids(builder.allocator()), ); - if reads_unmapped_theme(&plain) { - return Err(PropsRulesError::Theme(unmapped_theme_error( - "styled", &plain, - ))); - } - if function && reads_unmapped_theme(unwrap_syntax_only(value)) { + 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", value, + "styled", unmapped, ))); } let index = self.values.len().to_string(); diff --git a/libs/extractor/src/extractor/styled_props_self_reference.rs b/libs/extractor/src/extractor/styled_props_self_reference.rs index 6676f3fe1..2a3966679 100644 --- a/libs/extractor/src/extractor/styled_props_self_reference.rs +++ b/libs/extractor/src/extractor/styled_props_self_reference.rs @@ -19,10 +19,14 @@ pub(super) fn error(style: &Expression<'_>) -> Option<(u32, String)> { // 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, - style.clone_in(&allocator), + Expression::FunctionExpression(value), &builder, ))], &builder, @@ -39,9 +43,8 @@ pub(super) fn error(style: &Expression<'_>) -> Option<(u32, String)> { ); let semantic = SemanticBuilder::new().build(&program).semantic; let scoping = semantic.scoping(); - let symbol = scoping.symbol_ids().find(|symbol| { - scoping.symbol_span(*symbol) == binding.span && scoping.symbol_name(*symbol) == name - })?; + // 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() @@ -57,3 +60,7 @@ pub(super) fn error(style: &Expression<'_>) -> Option<(u32, String)> { ) }) } + +#[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 index 377e5ebe5..1e9e52660 100644 --- a/libs/extractor/src/extractor/styled_props_values.rs +++ b/libs/extractor/src/extractor/styled_props_values.rs @@ -31,11 +31,7 @@ pub(super) fn analysis<'a>( 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() - || crate::utils::js_number_literal(value).is_some() - || matches!( - value, - Expression::BooleanLiteral(_) | Expression::NullLiteral(_) - ) + || matches!(value, Expression::NullLiteral(_)) } pub(super) fn zero<'a>(builder: &AstBuilder<'a>) -> Expression<'a> { diff --git a/libs/extractor/src/styled_source_tests/props_function_bindings.rs b/libs/extractor/src/styled_source_tests/props_function_bindings.rs index b23850aa0..5bdfa1acf 100644 --- a/libs/extractor/src/styled_source_tests/props_function_bindings.rs +++ b/libs/extractor/src/styled_source_tests/props_function_bindings.rs @@ -5,6 +5,9 @@ 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( 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/visit.rs b/libs/extractor/src/visit.rs index 2747c971c..8e3173588 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1558,20 +1558,18 @@ impl<'a> DevupVisitor<'a> { if self.local_objects.is_empty() { return; } - let mut errors = Vec::new(); 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) { - errors.push(( + self.errors.push(( part.span().start, build_time_error(api, &readable_code(part), LOCAL_STYLES), )); } } - self.errors.extend(errors); } /// Whether `part` reads a binding `local_styles` holds