From 001a981616717111695e5d28eb11e719c43c43f9 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 2 Oct 2026 14:32:11 +0900 Subject: [PATCH 1/2] fix(extractor): report css() classes used as nested rules Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_nested_css_results.json | 7 ++++ bindings/devup-ui-wasm/src/lib.rs | 8 ++-- libs/css/src/theme_tokens.rs | 2 +- libs/extractor/src/lib.rs | 34 ++++++++++++++-- libs/extractor/src/style_values.rs | 40 +++++++++++++++++++ libs/extractor/src/tailwind.rs | 4 +- libs/extractor/src/visit.rs | 10 +++++ 7 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 .changepacks/changepack_log_nested_css_results.json diff --git a/.changepacks/changepack_log_nested_css_results.json b/.changepacks/changepack_log_nested_css_results.json new file mode 100644 index 000000000..fae5e5ec7 --- /dev/null +++ b/.changepacks/changepack_log_nested_css_results.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "A class from css() written as the value of a nested rule (`css({ '&:hover': hotpink })`) is a build error naming the file, line, column and binding, with the fix of spreading the rule object instead; the nested rule used to be silently dropped, leaving an empty class", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/bindings/devup-ui-wasm/src/lib.rs b/bindings/devup-ui-wasm/src/lib.rs index 4560410f3..0a2347ac2 100644 --- a/bindings/devup-ui-wasm/src/lib.rs +++ b/bindings/devup-ui-wasm/src/lib.rs @@ -1551,7 +1551,7 @@ mod tests { ); // Test getters - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert_eq!(output.css_file(), Some("devup-ui-0.css".to_string())); assert_eq!(output.map(), Some("//# sourceMappingURL=test".to_string())); assert!(output.css().is_some()); @@ -1951,7 +1951,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_some()); } @@ -1975,7 +1975,7 @@ mod tests { assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code().is_empty()); + assert_ne!(output.code(), ""); assert!(output.map().is_none()); } @@ -2000,7 +2000,7 @@ mod tests { assert!(result.is_err()); if let Err(error) = result { - assert!(!error.is_empty()); + assert_ne!(error, ""); } } diff --git a/libs/css/src/theme_tokens.rs b/libs/css/src/theme_tokens.rs index d8d8fb021..3b2346aed 100644 --- a/libs/css/src/theme_tokens.rs +++ b/libs/css/src/theme_tokens.rs @@ -119,7 +119,7 @@ mod tests { set_typography_keys(vec!["body".to_string(), "title".to_string()]); assert_eq!(get_typography_keys(), vec!["body", "title"]); set_typography_keys(vec![]); - assert!(get_typography_keys().is_empty()); + assert_eq!(get_typography_keys(), Vec::::new()); } #[test] diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 68981f36c..265cd3af3 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -836,8 +836,8 @@ mod tests { alternate: None, }; - assert!(empty.extract().is_empty()); - assert!(empty.into_extract().is_empty()); + assert_eq!(empty.extract(), vec![]); + assert_eq!(empty.into_extract(), vec![]); } #[test] @@ -11201,6 +11201,32 @@ let color = "red"; )); } + #[test] + #[serial] + fn test_css_class_as_nested_rule_is_an_error() { + reset_class_map(); + reset_file_map(); + let error = extract( + "test.tsx", + "import { css } from '@devup-ui/react' +const hotpink = css({ color: 'hotpink' }) +export const hover = css({ '&:hover,&:focus': hotpink, color: other })", + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + }, + ) + .unwrap_err() + .to_string(); + assert!( + error.contains("test.tsx:3:47: `css()` cannot use `hotpink` at build time: a class from `css()` cannot be the value of a nested rule"), + "{error}" + ); + } + #[test] #[serial] fn test_keyframes_no_args() { @@ -13598,7 +13624,7 @@ globalCss({ ); assert!(result.is_ok()); let output = result.unwrap(); - assert!(!output.code.is_empty()); + assert_ne!(output.code, ""); } #[test] @@ -18778,7 +18804,7 @@ export const k = styled('div')({ color: SIZE });", &memory_resolver(CONSTANT_MODULES), ) .unwrap(); - assert!(without_imports.dependencies.is_empty()); + assert_eq!(without_imports.dependencies.len(), 0); let without_constants = extract_with_modules( "/src/Handler.tsx", "import { Box } from '@devup-ui/react';\nimport { handler } from './handler';\nexport const a = ;", diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 77dc8f569..794256b74 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -62,6 +62,22 @@ impl StyleValues { } } + /// The `css()` classes `expression` writes as the value of a rule object's + /// property, such as `{ '&:hover': hotpink }`, by offset and name: a class + /// is not rules, so the nested selector would get nothing + pub fn nested_classes(&self, expression: &Expression<'_>) -> Vec<(u32, String)> { + let Some(scoping) = self.scoping.as_ref() else { + return Vec::new(); + }; + let mut nested = NestedClasses { + scoping, + values: &self.values, + found: Vec::new(), + }; + oxc_ast_visit::Visit::visit_expression(&mut nested, expression); + nested.found + } + fn reads<'s, 'a>(&'s self, ast: &'s AstBuilder<'a>) -> Option> { let scoping = self.scoping.as_ref()?; (!self.values.is_empty()).then_some(Reads { @@ -74,6 +90,30 @@ impl StyleValues { } } +struct NestedClasses<'s> { + scoping: &'s Scoping, + values: &'s FxHashMap, + found: Vec<(u32, String)>, +} + +impl<'a> oxc_ast_visit::Visit<'a> for NestedClasses<'_> { + fn visit_object_property(&mut self, it: &oxc_ast::ast::ObjectProperty<'a>) { + if let Expression::Identifier(identifier) = &it.value + && let Some(StyleValue::Class(_)) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.values.get(&symbol)) + { + self.found.push(( + oxc_span::GetSpan::span(&it.value).start, + identifier.name.to_string(), + )); + } + oxc_ast_visit::walk::walk_object_property(self, it); + } +} + struct Reads<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, diff --git a/libs/extractor/src/tailwind.rs b/libs/extractor/src/tailwind.rs index 87e9f5178..70643aa7c 100644 --- a/libs/extractor/src/tailwind.rs +++ b/libs/extractor/src/tailwind.rs @@ -284,7 +284,7 @@ pub struct TailwindClass { /// non-overlapping) but mutates the existing buffer instead of allocating a new /// `String`. `needle` must be non-empty. fn remove_all_substr(haystack: &mut String, needle: &str) { - debug_assert!(!needle.is_empty()); + debug_assert_ne!(needle, ""); let mut search_from = 0; while let Some(rel) = haystack[search_from..].find(needle) { let at = search_from + rel; @@ -3934,7 +3934,7 @@ mod tests { #[test] fn test_empty_string() { let styles = parse_tailwind_to_styles(""); - assert!(styles.is_empty()); + assert_eq!(styles, vec![]); } #[test] diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 93846123e..86aceb421 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -1609,6 +1609,16 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { Argument::SpreadElement(spread) => &mut spread.argument, argument => argument.to_expression_mut(), }; + for (offset, name) in self.style_values.nested_classes(expression) { + self.errors.push(( + offset, + build_time_error( + "css", + &name, + "a class from `css()` cannot be the value of a nested rule; spread the rule object it was made from instead, such as `'&:hover': { ...hotpinkRules }`", + ), + )); + } self.style_values.read_in(&self.ast, expression); } let offset = call.span.start; From 381386682b0c25cfbf35628c1d1162f15147ce96 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Fri, 16 Oct 2026 02:36:58 +0900 Subject: [PATCH 2/2] fix(extractor): expand static css results under nested selectors Refs #690 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_nested_css_results.json | 2 +- libs/css/src/style_selector.rs | 21 ++ .../extract_style_from_expression.rs | 4 +- libs/extractor/src/lib.rs | 202 +++++++++++++++- libs/extractor/src/style_values.rs | 225 +++++++++++++----- libs/extractor/src/utils.rs | 17 +- libs/extractor/src/visit.rs | 28 ++- 7 files changed, 418 insertions(+), 81 deletions(-) diff --git a/.changepacks/changepack_log_nested_css_results.json b/.changepacks/changepack_log_nested_css_results.json index fae5e5ec7..efb4716d9 100644 --- a/.changepacks/changepack_log_nested_css_results.json +++ b/.changepacks/changepack_log_nested_css_results.json @@ -2,6 +2,6 @@ "changes": { "bindings/devup-ui-wasm/package.json": "Patch" }, - "note": "A class from css() written as the value of a nested rule (`css({ '&:hover': hotpink })`) is a build error naming the file, line, column and binding, with the fix of spreading the rule object instead; the nested rule used to be silently dropped, leaving an empty class", + "note": "Statically known css() results used as nested rule values expand into their source rules under each top-level comma-separated selector, preserving responsive values, nested selectors, layers and style order without runtime styling. Unknown rule sources retain a located build error naming the value and its static-value requirement.", "date": "2026-10-01T00:00:00.000Z" } diff --git a/libs/css/src/style_selector.rs b/libs/css/src/style_selector.rs index fab4dca4b..93fa5577d 100644 --- a/libs/css/src/style_selector.rs +++ b/libs/css/src/style_selector.rs @@ -110,8 +110,25 @@ pub fn split_selector_list(selector: &str) -> Vec<&str> { let mut parts = vec![]; let mut depth = 0usize; let mut start = 0; + let mut quote = None; + let mut escaped = false; for (index, c) in selector.char_indices() { + if escaped { + escaped = false; + continue; + } + if c == '\\' { + escaped = true; + continue; + } + if let Some(delimiter) = quote { + if c == delimiter { + quote = None; + } + continue; + } match c { + '\'' | '"' => quote = Some(c), '(' | '[' => depth += 1, ')' | ']' => depth = depth.saturating_sub(1), ',' if depth == 0 => { @@ -789,6 +806,10 @@ mod tests { #[case("&:hover", vec!["&:hover"])] #[case(" a > b , i ", vec!["a > b", "i"])] #[case(":is(a, b), [title=\"a,b\"]", vec![":is(a, b)", "[title=\"a,b\"]"])] + #[case(r#"&[title="a,]b"], &:focus"#, vec![r#"&[title="a,]b"]"#, "&:focus"])] + #[case(r"&.a\,b, &:focus", vec![r"&.a\,b", "&:focus"])] + #[case(r"&.a\(, &:focus", vec![r"&.a\(", "&:focus"])] + #[case(r#"&[title="a\",]b"], &:focus"#, vec![r#"&[title="a\",]b"]"#, "&:focus"])] fn test_split_selector_list(#[case] selector: &str, #[case] expected: Vec<&str>) { assert_eq!(split_selector_list(selector), expected); } diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 0f26950ca..543c9a3bf 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -392,7 +392,7 @@ pub fn extract_style_from_expression<'a>( ast_builder, part, offset, - expression, + &mut expression.clone_in(ast_builder.allocator()), level, selector, literal_handling, @@ -437,7 +437,7 @@ pub fn extract_style_from_expression<'a>( ast_builder, part, offset, - &mut o.value, + &mut o.value.clone_in(ast_builder.allocator()), level, selector, literal_handling, diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index cfb0b6a91..f1e38ab50 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11375,16 +11375,70 @@ export const e = spin.toString()", assert!(extract("a.css.ts", code, option(false)).is_ok()); } - #[test] + #[rstest] + #[case::user_example( + "{ color: 'hotpink' }", + "'&:hover,&:focus': hotpink", + "'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }" + )] + #[case::inner_comma( + "{ color: 'hotpink' }", + "'&:is(a,b), &:not(.x,.y)': hotpink", + "'&:is(a,b)': { ...hotpinkRules }, '&:not(.x,.y)': { ...hotpinkRules }" + )] + #[case::three_selectors( + "{ color: 'hotpink' }", + "' &:hover, &:focus, &:active ': hotpink", + "'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }, '&:active': { ...hotpinkRules }" + )] + #[case::responsive( + "{ color: ['hotpink', null, 'blue'], p: [1, 2] }", + "'&:hover,&:focus': hotpink", + "'&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }" + )] + #[case::nested_layer_order( + "{ '@layer': { base: { color: 'hotpink', _active: { color: ['red', 'blue'] } } } }", + "styleOrder: 3, '&:hover,&:focus': hotpink, '&:hover': { color: 'green' }", + "styleOrder: 3, '&:hover': { ...hotpinkRules }, '&:focus': { ...hotpinkRules }, '&:hover': { color: 'green' }" + )] + #[case::quoted_commas( + "{ color: 'hotpink' }", + r#"'&[data-x=a,b], &[title="a,]b"]': hotpink"#, + r#"'&[data-x=a,b]': { ...hotpinkRules }, '&[title="a,]b"]': { ...hotpinkRules }"# + )] #[serial] - fn test_css_class_as_nested_rule_is_an_error() { + fn test_css_nested_static_result( + #[case] rules: &str, + #[case] nested: &str, + #[case] expanded: &str, + #[values("css(hotpinkRules)", "css({}, hotpinkRules)")] source: &str, + ) { + let compile = |body| { + reset_class_map(); + reset_file_map(); + extract( + "test.tsx", + &format!("import {{ css }} from '@devup-ui/react'; const hotpinkRules = {rules}; const hotpink = {source}; export const hover = css({{ {body} }});"), + ExtractOption::default(), + ) + .unwrap() + }; + let expected = compile(expanded); + let actual = compile(nested); + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + } + + #[rstest] + #[case::unknown("")] + #[case::external_class("const hotpink = css('external', { color: 'red' });")] + #[serial] + fn test_css_nested_static_result_unknown_error(#[case] binding: &str) { reset_class_map(); reset_file_map(); let error = extract( "test.tsx", - "import { css } from '@devup-ui/react' -const hotpink = css({ color: 'hotpink' }) -export const hover = css({ '&:hover,&:focus': hotpink, color: other })", + &format!("import {{ css }} from '@devup-ui/react'\n{binding}\nexport const hover = css({{ '&:hover,&:focus': hotpink }})"), ExtractOption { package: "@devup-ui/react".to_string(), css_dir: "@devup-ui/react".to_string(), @@ -11396,11 +11450,147 @@ export const hover = css({ '&:hover,&:focus': hotpink, color: other })", .unwrap_err() .to_string(); assert!( - error.contains("test.tsx:3:47: `css()` cannot use `hotpink` at build time: a class from `css()` cannot be the value of a nested rule"), + error.starts_with("test.tsx:3:47: `css()` cannot use `hotpink` at build time:") + && error.contains("its values must be literals, theme tokens or constants"), "{error}" ); } + #[rstest] + #[case::composed( + "const hotpink = css(base, { p: 2 });", + "hotpink", + "{ color: 'hotpink', p: 2 }" + )] + #[case::chained( + "const hotpink = css({ _active: base });", + "hotpink", + "{ _active: { color: 'hotpink' } }" + )] + #[case::keyframes( + "const hotpink = css({ animationName: spin });", + "hotpink", + "{ animationName: spin }" + )] + #[case::alias("const hotpink = base;", "hotpink", "{ color: 'hotpink' }")] + #[case::inline("", "css({ color: 'hotpink' })", "{ color: 'hotpink' }")] + #[case::composed_later_wins( + "const hotpink = css(base, { color: 'blue', p: 2 });", + "hotpink", + "{ color: 'blue', p: 2 }" + )] + #[case::alias_chain( + "const warm = base; const hotpink = warm;", + "hotpink", + "{ color: 'hotpink' }" + )] + #[case::selector_record( + "const hotpink = css({ selectors: { '&:active': base } });", + "hotpink", + "{ selectors: { '&:active': { color: 'hotpink' } } }" + )] + #[case::declaration_string( + "const hotpink = css({ content: base });", + "hotpink", + "{ content: `${base}` }" + )] + #[case::spread_composed( + "const hotpink = css(...[base, { p: 2 }]);", + "hotpink", + "{ color: 'hotpink', p: 2 }" + )] + #[case::adjacent_spreads( + "const hotpink = css(...[base], ...[{ color: 'blue', p: 2 }]);", + "hotpink", + "{ color: 'blue', p: 2 }" + )] + #[case::nested_array_spread( + "const hotpink = css(...[...[base], { p: 2 }]);", + "hotpink", + "{ color: 'hotpink', p: 2 }" + )] + #[case::direct_spread_source( + "const hotpink = css(...{ _active: base });", + "hotpink", + "{ _active: { color: 'hotpink' } }" + )] + #[case::spread_declaration_string( + "const hotpink = css(...[{ content: base }, { p: 2 }]);", + "hotpink", + "{ content: `${base}`, p: 2 }" + )] + #[case::layer_record( + "", + "{ '@layer': { colors: base } }", + "{ '@layer': { colors: { color: 'hotpink' } } }" + )] + #[case::media_record( + "", + "{ '@media': { print: base } }", + "{ '@media': { print: { color: 'hotpink' } } }" + )] + #[case::supports_record( + "", + "{ '@supports': { '(display: grid)': base } }", + "{ '@supports': { '(display: grid)': { color: 'hotpink' } } }" + )] + #[case::container_record( + "", + "{ '@container': { '(min-width: 10px)': base } }", + "{ '@container': { '(min-width: 10px)': { color: 'hotpink' } } }" + )] + #[case::underscore_media_record( + "", + "{ _media: { print: base } }", + "{ _media: { print: { color: 'hotpink' } } }" + )] + #[case::record_declaration_string( + "", + "{ '@layer': { colors: { content: base } } }", + "{ '@layer': { colors: { content: `${base}` } } }" + )] + #[serial] + fn test_css_nested_static_source_references( + #[case] binding: &str, + #[case] value: &str, + #[case] expanded: &str, + ) { + let compile = |nested: &str| { + reset_class_map(); + reset_file_map(); + extract( + "test.tsx", + &format!("import {{ css, keyframes }} from '@devup-ui/react'; const spin = keyframes({{ from: {{ opacity: 0 }}, to: {{ opacity: 1 }} }}); const base = css({{ color: 'hotpink' }}); {binding} export const hover = css({{ styleOrder: 3, '@layer': {{ base: {{ '&:hover,&:focus': {nested} }} }} }});"), + ExtractOption::default(), + ).unwrap() + }; + let expected = compile(expanded); + let actual = compile(value); + assert_eq!(actual.styles, expected.styles); + assert_eq!(actual.code, expected.code); + } + + #[test] + #[serial] + fn test_css_nested_inline_without_binding() { + let compile = |source| { + reset_class_map(); + reset_file_map(); + extract("test.tsx", source, ExtractOption::default()).unwrap() + }; + let expected = compile( + "import { css } from '@devup-ui/react'; css({ color: 'hotpink' }); export const hover = css({ '&:hover': { color: 'hotpink' }, '&:focus': { color: 'hotpink' } });", + ); + let actual = compile( + "import { css } from '@devup-ui/react'; export const hover = css({ '&:hover,&:focus': css({ color: 'hotpink' }) });", + ); + assert_eq!(actual.styles, expected.styles); + assert_eq!( + actual.code.split("export const").last(), + expected.code.split("export const").last() + ); + } + #[test] #[serial] fn test_keyframes_no_args() { diff --git a/libs/extractor/src/style_values.rs b/libs/extractor/src/style_values.rs index 88fc24c7e..da09280e5 100644 --- a/libs/extractor/src/style_values.rs +++ b/libs/extractor/src/style_values.rs @@ -2,8 +2,8 @@ //! `const fadeIn = keyframes({ ... })`: the build gives it a name, so styles //! reading the binding read that name -use oxc_allocator::{FromIn, GetAllocator}; -use oxc_ast::ast::{BindingPattern, Expression, Str, TemplateLiteral}; +use oxc_allocator::{CloneIn, FromIn, GetAllocator}; +use oxc_ast::ast::{Argument, BindingPattern, Expression, Str, TemplateLiteral}; use oxc_ast::builder::AstBuilder; use oxc_ast_visit::{VisitMut, walk_mut}; use oxc_semantic::Scoping; @@ -12,6 +12,7 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::FxHashMap; /// The name a style API call gives +#[derive(Clone)] pub enum StyleValue { /// A class `css()` gives: in CSS text it is a mixin, composed rather than /// written as text, so it is read only outside CSS text @@ -21,20 +22,24 @@ pub enum StyleValue { } #[derive(Default)] -pub struct StyleValues { +pub struct StyleValues<'a> { scoping: Option, values: FxHashMap, /// The `const`s the file binds to a string, which a `className` reads as /// the classes they hold texts: FxHashMap, + rules: FxHashMap>, + calls: FxHashMap>, } -impl StyleValues { +impl<'a> StyleValues<'a> { pub fn new(scoping: Scoping) -> Self { Self { scoping: Some(scoping), values: FxHashMap::default(), texts: FxHashMap::default(), + rules: FxHashMap::default(), + calls: FxHashMap::default(), } } @@ -57,7 +62,7 @@ impl StyleValues { } /// `expression`, a `className`, reading the strings the constants hold - pub fn read_in_class<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { + pub fn read_in_class(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { if let Some(scoping) = self.scoping.as_ref().filter(|_| !self.texts.is_empty()) { ClassReads { ast, @@ -69,7 +74,7 @@ impl StyleValues { } /// `expression` reading what the bindings recorded hold - pub fn read_in<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { + pub fn read_in(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { if let Some(mut reads) = self.reads(ast, false) { reads.visit_expression(expression); } @@ -77,38 +82,83 @@ impl StyleValues { /// `expression` as class names, which read a class in a template literal /// too: there it is a string to join, not CSS text - pub fn read_in_class_names<'a>(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { + pub fn read_in_class_names(&self, ast: &AstBuilder<'a>, expression: &mut Expression<'a>) { if let Some(mut reads) = self.reads(ast, true) { reads.visit_expression(expression); } } /// The CSS text `template` reading what the bindings recorded hold - pub fn read_in_text<'a>(&self, ast: &AstBuilder<'a>, template: &mut TemplateLiteral<'a>) { + pub fn read_in_text(&self, ast: &AstBuilder<'a>, template: &mut TemplateLiteral<'a>) { if let Some(mut reads) = self.reads(ast, false) { reads.visit_template_literal(template); } } - /// The `css()` classes `expression` writes as the value of a rule object's - /// property, such as `{ '&:hover': hotpink }`, by offset and name: a class - /// is not rules, so the nested selector would get nothing - pub fn nested_classes(&self, expression: &Expression<'_>) -> Vec<(u32, String)> { - let Some(scoping) = self.scoping.as_ref() else { - return Vec::new(); + /// Retain the static source, not the generated class text, for nested reuse. + pub fn record_rules( + &mut self, + ast: &AstBuilder<'a>, + offset: u32, + arguments: &[oxc_ast::ast::Argument<'a>], + ) { + let mut arguments = oxc_allocator::Vec::from_iter_in( + arguments + .iter() + .map(|argument| argument.clone_in_with_semantic_ids(ast.allocator())), + ast, + ); + for argument in &mut arguments { + let expression = match argument { + Argument::SpreadElement(spread) => &mut spread.argument, + argument => argument.to_expression_mut(), + }; + if let Some(mut reads) = self.reads(ast, false) { + reads.source = true; + reads.visit_expression(expression); + } + } + let rules = if crate::utils::reads_directly(&arguments) { + arguments + .first() + .map(|argument| match argument { + Argument::SpreadElement(spread) => &spread.argument, + argument => argument.to_expression(), + }) + .map(|rule| rule.clone_in_with_semantic_ids(ast.allocator())) + } else { + crate::utils::style_arguments(ast, &arguments) + .filter(|parts| parts.classes.is_empty()) + .map(|parts| parts.rules) }; - let mut nested = NestedClasses { - scoping, - values: &self.values, - found: Vec::new(), + if let Some(rules) = rules { + self.calls.insert(offset, rules); + } + } + + pub fn bind_rules(&mut self, ast: &AstBuilder<'a>, symbol: SymbolId, value: &Expression<'a>) { + let rules = match crate::utils::unwrap_syntax_only(value) { + Expression::StringLiteral(text) => self.calls.get(&text.span.start), + Expression::Identifier(identifier) => { + let source = identifier.reference_id.get().and_then(|reference| { + self.scoping.as_ref()?.get_reference(reference).symbol_id() + }); + if let Some(value) = source.and_then(|source| self.values.get(&source)).cloned() { + self.values.insert(symbol, value); + } + source.and_then(|source| self.rules.get(&source)) + } + _ => None, }; - oxc_ast_visit::Visit::visit_expression(&mut nested, expression); - nested.found + if let Some(rules) = rules { + self.rules + .insert(symbol, rules.clone_in_with_semantic_ids(ast.allocator())); + } } /// `program` with each `name.getName()` on a `keyframes()` binding read as /// the name it gives, as styled-components' keyframes object returns it - pub fn read_names<'a>(&self, ast: &AstBuilder<'a>, program: &mut oxc_ast::ast::Program<'a>) { + pub fn read_names(&self, ast: &AstBuilder<'a>, program: &mut oxc_ast::ast::Program<'a>) { let Some(scoping) = self.scoping.as_ref() else { return; }; @@ -126,42 +176,25 @@ impl StyleValues { } } - fn reads<'s, 'a>(&'s self, ast: &'s AstBuilder<'a>, in_rules: bool) -> Option> { + fn reads<'s>(&'s self, ast: &'s AstBuilder<'a>, in_rules: bool) -> Option> { let scoping = self.scoping.as_ref()?; - (!self.values.is_empty()).then_some(Reads { + (!self.values.is_empty() || !self.calls.is_empty()).then_some(Reads { ast, scoping, values: &self.values, - in_text: false, - in_rules, + context: if in_rules { + ReadContext::Rules + } else { + ReadContext::Value + }, + rules: &self.rules, + calls: &self.calls, + source: false, + nested_rules: !in_rules, }) } } -struct NestedClasses<'s> { - scoping: &'s Scoping, - values: &'s FxHashMap, - found: Vec<(u32, String)>, -} - -impl<'a> oxc_ast_visit::Visit<'a> for NestedClasses<'_> { - fn visit_object_property(&mut self, it: &oxc_ast::ast::ObjectProperty<'a>) { - if let Expression::Identifier(identifier) = &it.value - && let Some(StyleValue::Class(_)) = identifier - .reference_id - .get() - .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) - .and_then(|symbol| self.values.get(&symbol)) - { - self.found.push(( - oxc_span::GetSpan::span(&it.value).start, - identifier.name.to_string(), - )); - } - oxc_ast_visit::walk::walk_object_property(self, it); - } -} - struct Names<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, @@ -193,16 +226,45 @@ impl<'a> VisitMut<'a> for Names<'_, 'a> { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum ReadContext { + Value, + CssText, + Rules, + RuleRecord, +} + struct Reads<'s, 'a> { ast: &'s AstBuilder<'a>, scoping: &'s Scoping, values: &'s FxHashMap, - in_text: bool, - in_rules: bool, + context: ReadContext, + rules: &'s FxHashMap>, + calls: &'s FxHashMap>, + source: bool, + nested_rules: bool, } impl<'a> VisitMut<'a> for Reads<'_, 'a> { fn visit_expression(&mut self, it: &mut Expression<'a>) { + if self.source + && let Expression::Identifier(identifier) = it + && let Some(rules) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + .and_then(|symbol| self.rules.get(&symbol)) + { + *it = rules.clone_in_with_semantic_ids(self.ast.allocator()); + return; + } + if self.source + && let Expression::StringLiteral(text) = it + && let Some(rules) = self.calls.get(&text.span.start) + { + *it = rules.clone_in_with_semantic_ids(self.ast.allocator()); + return; + } if let Expression::Identifier(identifier) = it && let Some(value) = identifier .reference_id @@ -211,7 +273,7 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { .and_then(|symbol| self.values.get(&symbol)) { let value = match value { - StyleValue::Class(_) if self.in_text => return, + StyleValue::Class(_) if self.context == ReadContext::CssText => return, StyleValue::Class(value) | StyleValue::Keyframes(value) => value, }; *it = Expression::new_string_literal( @@ -226,20 +288,69 @@ impl<'a> VisitMut<'a> for Reads<'_, 'a> { } fn visit_template_literal(&mut self, it: &mut TemplateLiteral<'a>) { - let outer = std::mem::replace(&mut self.in_text, !self.in_rules); + let context = match self.context { + ReadContext::Value | ReadContext::CssText => ReadContext::CssText, + ReadContext::Rules | ReadContext::RuleRecord => ReadContext::Rules, + }; + let outer = std::mem::replace(&mut self.context, context); walk_mut::walk_template_literal(self, it); - self.in_text = outer; + self.context = outer; } /// A rule object holds strings, not CSS text: a template in its keys or /// values reads a class as the text it is fn visit_object_expression(&mut self, it: &mut oxc_ast::ast::ObjectExpression<'a>) { + let context = match self.context { + ReadContext::RuleRecord => ReadContext::RuleRecord, + ReadContext::Value | ReadContext::CssText | ReadContext::Rules => ReadContext::Rules, + }; let outer = ( - std::mem::replace(&mut self.in_rules, true), - std::mem::replace(&mut self.in_text, false), + std::mem::replace(&mut self.context, context), + std::mem::replace(&mut self.source, false), ); walk_mut::walk_object_expression(self, it); - (self.in_rules, self.in_text) = outer; + (self.context, self.source) = outer; + } + + fn visit_object_property(&mut self, it: &mut oxc_ast::ast::ObjectProperty<'a>) { + let key = it.key.static_name(); + let nested = self.nested_rules + && (self.context == ReadContext::RuleRecord + || key.as_deref().is_some_and(|key| { + key.contains('&') || key.starts_with([':', '_', '@']) || key == "selectors" + })); + if !nested { + walk_mut::walk_object_property(self, it); + return; + } + if let Expression::Identifier(identifier) = &it.value + && let Some(symbol) = identifier + .reference_id + .get() + .and_then(|reference| self.scoping.get_reference(reference).symbol_id()) + && matches!(self.values.get(&symbol), Some(StyleValue::Class(_))) + && !self.rules.contains_key(&symbol) + { + return; + } + let outer = std::mem::replace(&mut self.source, true); + let context = std::mem::replace( + &mut self.context, + if key.as_deref().is_some_and(|key| { + key == "selectors" + || key == "@layer" + || crate::extractor::extract_style_from_expression::at_rule_record_kind(key) + .is_some() + }) { + ReadContext::RuleRecord + } else { + ReadContext::Rules + }, + ); + self.visit_expression(&mut it.value); + self.context = context; + self.source = outer; + self.visit_property_key(&mut it.key); } } diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 15a011a7d..27700b4b9 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -5,9 +5,9 @@ use css::utils::to_kebab_case; use oxc_allocator::{Allocator, CloneIn, GetAllocator}; use oxc_ast::{ ast::{ - Argument, CallExpression, Expression, ExpressionStatement, IdentifierName, - JSXAttributeValue, ObjectExpression, ObjectProperty, ObjectPropertyKind, Program, - PropertyKey, Statement, StaticMemberExpression, + Argument, ArrayExpressionElement, CallExpression, Expression, ExpressionStatement, + IdentifierName, JSXAttributeValue, ObjectExpression, ObjectProperty, ObjectPropertyKind, + Program, PropertyKey, Statement, StaticMemberExpression, }, builder::AstBuilder, }; @@ -1079,7 +1079,16 @@ fn collect_style_parts<'b, 'a>( match unwrap_syntax_only(expression) { Expression::ArrayExpression(array) => { for element in &array.elements { - collect_style_parts(ast_builder, element.as_expression()?, parts, classes)?; + let expression = match element { + ArrayExpressionElement::SpreadElement(spread) => { + match unwrap_syntax_only(&spread.argument) { + array @ Expression::ArrayExpression(_) => array, + _ => return None, + } + } + element => element.as_expression()?, + }; + collect_style_parts(ast_builder, expression, parts, classes)?; } return Some(()); } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index cae99c79e..f864fe1e7 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -181,7 +181,7 @@ pub struct DevupVisitor<'a> { /// name of their own runtime_types: usize, /// The classes and keyframes names the file binds to a `const` - style_values: crate::style_values::StyleValues, + style_values: crate::style_values::StyleValues<'a>, /// What the build compiles away: the imports of the package it removes, /// and the bindings aliasing them, which only its calls and elements read compiled_names: FxHashSet, @@ -1694,6 +1694,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if let Expression::CallExpression(call) = it { if let Some(util_type) = self.util_type(&call.callee) { + let source_span = call.span; + if util_type.is_css() { + self.style_values + .record_rules(&self.ast, source_span.start, &call.arguments); + } for argument in &mut call.arguments { let expression = match argument { Argument::SpreadElement(spread) => &mut spread.argument, @@ -1702,16 +1707,6 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { if util_type.class_call().is_some() { self.style_values.read_in_class_names(&self.ast, expression); } else { - for (offset, name) in self.style_values.nested_classes(expression) { - self.errors.push(( - offset, - build_time_error( - "css", - &name, - "a class from `css()` cannot be the value of a nested rule; spread the rule object it was made from instead, such as `'&:hover': { ...hotpinkRules }`", - ), - )); - } self.style_values.read_in(&self.ast, expression); } } @@ -1939,6 +1934,11 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { ); *it = flatten_classes(&self.ast, classes); } + if util_type.is_css() + && let Expression::StringLiteral(text) = it + { + text.span = source_span; + } } } else if let Expression::TaggedTemplateExpression(tag) = it && let Some(class_type) = self.util_type(&tag.tag) @@ -2339,6 +2339,12 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { walk_variable_declarator(self, it); + if let Some(symbol) = self.style_values.constant(&it.id) + && let Some(value) = &it.init + { + self.style_values.bind_rules(&self.ast, symbol, value); + } + if let Some((text, symbol)) = class_text { self.style_values.insert_text(symbol, text); }