From a73e6ec38678b6bc551cb86c65a7739048a328a5 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Oct 2026 05:05:48 +0900 Subject: [PATCH 1/3] fix(extractor): compose constant objects in css tags Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changepack_log_const_object_css_tags.json | 7 + .../const_object_css_tag_controls_tests.rs | 137 ++++++++++++ .../src/const_object_css_tag_policy_tests.rs | 120 +++++++++++ .../src/const_object_css_tag_tests.rs | 196 ++++++++++++++++++ .../extract_style_from_expression.rs | 88 +++++--- .../extract_style_from_member_expression.rs | 24 ++- libs/extractor/src/imported_constants.rs | 75 ++++++- .../src/imported_constants_px_tests.rs | 83 ++++++++ libs/extractor/src/lib.rs | 9 + libs/extractor/src/visit.rs | 76 ++++--- 10 files changed, 754 insertions(+), 61 deletions(-) create mode 100644 .changepacks/changepack_log_const_object_css_tags.json create mode 100644 libs/extractor/src/const_object_css_tag_controls_tests.rs create mode 100644 libs/extractor/src/const_object_css_tag_policy_tests.rs create mode 100644 libs/extractor/src/const_object_css_tag_tests.rs create mode 100644 libs/extractor/src/imported_constants_px_tests.rs diff --git a/.changepacks/changepack_log_const_object_css_tags.json b/.changepacks/changepack_log_const_object_css_tags.json new file mode 100644 index 000000000..424dacd0a --- /dev/null +++ b/.changepacks/changepack_log_const_object_css_tags.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Compose constant object rules in css tags without runtime styling, preserve lexical tag bindings and numeric-unit marks, and report authored build errors for mutated, escaped or unresolved object mixins and dynamic object leaves.", + "date": "2026-10-20T19:08:10.516Z" +} diff --git a/libs/extractor/src/const_object_css_tag_controls_tests.rs b/libs/extractor/src/const_object_css_tag_controls_tests.rs new file mode 100644 index 000000000..67b76a046 --- /dev/null +++ b/libs/extractor/src/const_object_css_tag_controls_tests.rs @@ -0,0 +1,137 @@ +use super::*; +use oxc_ast::ast::Expression; +use oxc_ast_visit::Visit; + +#[rstest] +#[case::named_alias(("import { css as makeCss } from '@devup-ui/react';", "", "makeCss`${rules}`", &["color-0-blue-_a__c_hover-255"][..]))] +#[case::own_namespace(("import * as ui from '@devup-ui/react';", "", "ui.css`${rules}`", &["color-0-blue-_a__c_hover-255"][..]))] +#[case::plain(("import { css } from '@devup-ui/react';", "", "css`color: red; margin: 0;`", &["color-0-red--255", "margin-0-0--255"][..]))] +#[case::scalar_only(("import { css } from '@devup-ui/react';", "const tone = 'red'; const space = 0;", "css`color: ${tone}; margin: ${space};`", &["color-0-red--255", "margin-0-0--255"][..]))] +#[case::scalar_value_before_object(("import { css } from '@devup-ui/react';", "const tone = 'red';", "css`color: ${tone}; ${rules}; margin: 0;`", &["color-0-red--255", "color-0-blue-_a__c_hover-255", "margin-0-0--255"][..]))] +#[case::scalar_statement_before_object(("import { css } from '@devup-ui/react';", "const lead = 'color: red;';", "css`${lead} ${rules}; margin: 0;`", &["color-0-red--255", "color-0-blue-_a__c_hover-255", "margin-0-0--255"][..]))] +#[serial] +fn card_links_expected_styles_when_own_tags_take_recognition_or_fast_paths( + #[case] sample: (&str, &str, &str, &[&str]), + #[values(false, true)] single_css: bool, +) { + // Given: expected atoms are authored independently of extraction output. + let _state = crate::test_state::TestStateGuard::new(); + css::debug::set_debug(true); + let (import, binding, expression, expected) = sample; + let filename = "/src/tag-controls.ts"; + let source = format!( + "{import}\nconst rules = {{ selectors: {{ '&:hover': {{ color: 'blue' }} }} }};\n{binding}\nexport const card = {expression};" + ); + + // When + let output = extract( + filename, + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{source}\n{error}")); + + // Then: a selected card atom must also link to an actual emitted style. + assert_composed_export_atoms(&output.code, "card", expected); + let arena = Allocator::default(); + let parsed = Parser::new(&arena, &output.code, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + let Statement::ExportDeclaration(export) = parsed.program.body.last().unwrap() else { + panic!("expected exported card"); + }; + let oxc_ast::ast::Declaration::VariableDeclaration(declaration) = &export.declaration else { + panic!("expected card declaration"); + }; + let Expression::StringLiteral(classes) = declaration.declarations[0].init.as_ref().unwrap() + else { + panic!("expected compiled class string, not an object or tagged template"); + }; + for class in classes.value.split_whitespace() { + assert!(output.styles.iter().any(|style| { + matches!(style.extract((!single_css).then_some(filename)), Some(StyleProperty::ClassName(name)) if name == class) + }), "selected card class {class} has no emitted style: {output:?}"); + } + assert_eq!(classes.value.split_whitespace().count(), expected.len()); +} + +#[rstest] +#[case::shadowed(("", "css", "css"))] +#[case::unrelated(("import { html } from './markup';", "", "html"))] +#[case::shadowed_namespace(("import * as ui from '@devup-ui/react';", "ui", "ui.css"))] +#[serial] +fn unrelated_tags_retain_template_reads_when_own_css_is_in_scope( + #[case] sample: (&str, &str, &str), + #[values(false, true)] single_css: bool, +) { + struct RetainedTag<'s> { + name: &'s str, + count: usize, + } + impl<'a> Visit<'a> for RetainedTag<'_> { + fn visit_tagged_template_expression( + &mut self, + tagged: &oxc_ast::ast::TaggedTemplateExpression<'a>, + ) { + self.count += 1; + if self.name == "ui.css" { + let Expression::StaticMemberExpression(member) = &tagged.tag else { + panic!("namespace tag changed shape"); + }; + let Expression::Identifier(namespace) = &member.object else { + panic!("namespace root changed shape"); + }; + assert_eq!(namespace.name, "ui"); + assert_eq!(member.property.name, "css"); + } else { + let Expression::Identifier(name) = &tagged.tag else { + panic!("unrelated tag changed shape"); + }; + assert_eq!(name.name, self.name); + } + let quasis: Vec<_> = tagged + .quasi + .quasis + .iter() + .map(|quasi| quasi.value.raw.as_str()) + .collect(); + assert_eq!(quasis, ["before ", " after"]); + assert_eq!(tagged.quasi.expressions.len(), 1); + let Expression::Identifier(read) = &tagged.quasi.expressions[0] else { + panic!("ordinary object read was transformed"); + }; + assert_eq!(read.name, "rules"); + } + } + // Given: an unresolved object leaf must remain ordinary JavaScript here. + let _state = crate::test_state::TestStateGuard::new(); + let (import, parameter, tag) = sample; + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{import}\nconst rules = {{ color: globalThis.tone }};\nexport function render({parameter}) {{ return {tag}`before ${{rules}} after`; }}" + ); + + // When + let output = extract( + "/src/tag-controls.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{source}\n{error}")); + + // Then: inspect the retained tag and interpolation, not a code dump. + let arena = Allocator::default(); + let parsed = Parser::new(&arena, &output.code, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + let mut observer = RetainedTag { + name: tag, + count: 0, + }; + observer.visit_program(&parsed.program); + assert_eq!(observer.count, 1); + assert_eq!(output.styles.len(), 0); +} diff --git a/libs/extractor/src/const_object_css_tag_policy_tests.rs b/libs/extractor/src/const_object_css_tag_policy_tests.rs new file mode 100644 index 000000000..f8b1a5081 --- /dev/null +++ b/libs/extractor/src/const_object_css_tag_policy_tests.rs @@ -0,0 +1,120 @@ +use super::*; + +#[rstest] +#[case::identifier((" color:\n tone,", 4, 5, "tone"))] +#[case::computed_member((" color:\n palette[key],", 4, 5, "palette[key]"))] +#[case::call((" color:\n chooseTone(),", 4, 5, "chooseTone()"))] +#[case::sequence((" color:\n (tick(), tone),", 4, 6, "(tick(),tone)"))] +#[case::repeated_dead_branch((" color: false ? tone : 'red',\n background:\n tone,", 5, 5, "tone"))] +#[case::responsive((" color: ['red',\n tone],", 4, 5, "tone"))] +#[case::selector((" _hover: { color:\n tone },", 4, 5, "tone"))] +#[case::at_rule((" '@media': { '(min-width: 1px)': { color:\n tone } },", 4, 5, "tone"))] +#[case::layer((" '@layer': { base: { color:\n tone } },", 4, 5, "tone"))] +#[case::member_selected_object((" color: ({ active:\n tone, dead: 'red' })['active'],", 4, 5, "tone"))] +#[case::member_selected_array((" color: [\n tone, 'red'][0],", 4, 5, "tone"))] +#[case::member_runtime_spread((" color:\n ['red', ...tones][1],", 4, 5, "[`red`,...tones][1]"))] +#[case::normalized_template((" color:\n `${tone} !important`,", 4, 5, "`${tone}`"))] +#[serial] +fn live_dynamic_leaf_retains_authored_offset_when_object_tag_is_extracted( + #[case] sample: (&str, usize, usize, &str), + #[values(false, true)] single_css: bool, +) { + // Given: line/column and normalized code are independent authored expectations. + let _state = crate::test_state::TestStateGuard::new(); + let (rules, line, column, code) = sample; + let source = format!( + "import {{ css }} from '@devup-ui/react';\nexport const card = css`${{{{\n{rules}\n}}}}`;" + ); + // When + let error = extract( + "/src/policy.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_err() + .to_string(); + // Then + assert!( + error.starts_with(&format!("/src/policy.ts:{line}:{column}: ")), + "{error}" + ); + assert!( + error.contains(&format!("cannot use `{code}` at build time:")), + "{error}" + ); +} + +#[rstest] +#[case::spread(" ...{ color: tone }, color: 'red',")] +#[case::property(" ...{}, color: tone, color: 'red',")] +#[case::dead_branch(" color: false ? tone : 'red',")] +#[case::selected_member(" color: ({ dead: tone, live: 'red' })['live'],")] +#[serial] +fn static_rules_survive_when_dead_dynamic_values_are_discarded( + #[case] rules: &str, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + css::debug::set_debug(true); + let source = format!( + "import {{ css }} from '@devup-ui/react';\nexport const card = css`${{{{\n{rules}\n}}}}`;" + ); + // When + let output = extract( + "/src/policy.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + // Then + assert_composed_export_atoms(&output.code, "card", &["color-0-red--255"]); +} + +#[rstest] +#[case::ordinary_call("css({ color: tone })")] +#[case::composed_call("css(css({ background: 'red' }), { color: tone })")] +#[case::scalar_tag("css`color: ${tone};`")] +#[case::scalar_tag_with_object("css`${{ background: 'red' }}; color: ${tone};`")] +#[serial] +fn default_caller_keeps_container_diagnostic_when_value_is_dynamic(#[case] expression: &str) { + // Given: these caller offsets are the established pre-policy contract. + let _state = crate::test_state::TestStateGuard::new(); + let source = + format!("import {{ css }} from '@devup-ui/react';\nexport const card = {expression};"); + // When + let error = extract("/src/default.ts", &source, ExtractOption::default()) + .unwrap_err() + .to_string(); + // Then + assert!(error.starts_with("/src/default.ts:2:21: "), "{error}"); + assert!( + error.contains("cannot use `tone` at build time:"), + "{error}" + ); +} + +#[test] +#[serial] +fn enum_property_keeps_static_alternatives_when_object_tag_is_extracted() { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let source = "import { css } from '@devup-ui/react'; +export const card = css`${{ positioning: mode }}`;"; + // When + let output = extract("/src/enum.ts", source, ExtractOption::default()).unwrap(); + // Then + assert_eq!(output.styles.len(), 4); + assert!( + output + .styles + .iter() + .all(|style| matches!(style, ExtractStyleValue::Static(_))) + ); +} diff --git a/libs/extractor/src/const_object_css_tag_tests.rs b/libs/extractor/src/const_object_css_tag_tests.rs new file mode 100644 index 000000000..5d0a7aa3c --- /dev/null +++ b/libs/extractor/src/const_object_css_tag_tests.rs @@ -0,0 +1,196 @@ +use super::*; + +const HOVER: &str = "color-0-blue-_a__c_hover-255"; +const RULES: &str = "const rules = { selectors: { '&:hover': { color: 'blue' } } };"; +const MODULES: &[(&str, &str)] = &[( + "/src/rules.ts", + "export const rules = { selectors: { '&:hover': { color: 'blue' } } };", +)]; + +#[rstest] +#[case::record((RULES, "css`${rules}`", &[HOVER][..]))] +#[case::direct(("", "css`${{ selectors: { '&:hover': { color: 'blue' } } }}`", &[HOVER][..]))] +#[case::alias(("const rules = { selectors: { '&:hover': { color: 'blue' } } }; const alias = rules;", "css`${alias}`", &[HOVER][..]))] +#[case::imported(("import { rules as alias } from './rules';", "css`${alias}`", &[HOVER][..]))] +#[case::normal_call((RULES, "css(rules)", &[HOVER][..]))] +#[case::compiled_class(("const base = css({ color: 'red' });", "css`${base};`", &["color-0-red--255"][..]))] +#[case::mixed_scalar(("const rules = { selectors: { '&:hover': { color: 'blue' } } }; const tone = 'red';", "css`${rules}; color: ${tone}; margin: 0;`", &[HOVER, "color-0-red--255", "margin-0-0--255"][..]))] +#[case::follow_up(("const rules = { selectors: { '&:hover': { color: 'blue' } } }; const tag = css`${rules}; color: red;`;", "css(tag, { color: 'green', p: 1 })", &[HOVER, "color-0-green--255", "padding-0-4px--255"][..]))] +#[case::scalar_statement(("const text = 'color: red;';", "css`${text} margin: 0;`", &["color-0-red--255", "margin-0-0--255"][..]))] +#[case::object_after_text(("const rules = { color: 'blue', selectors: { '&:hover': { color: 'blue' } } };", "css`color: red; ${rules};`", &[HOVER, "color-0-blue--255"][..]))] +#[case::text_after_object(("const rules = { color: 'blue', selectors: { '&:hover': { color: 'blue' } } };", "css`${rules}; color: red;`", &[HOVER, "color-0-red--255"][..]))] +#[case::tag_last(("const rules = { color: 'blue', selectors: { '&:hover': { color: 'blue' } } }; const tag = css`color: red; ${rules};`;", "css({ color: 'green', p: 1 }, tag)", &[HOVER, "color-0-blue--255", "padding-0-4px--255"][..]))] +#[serial] +fn card_selects_linked_atoms_when_tag_statements_are_composed( + #[case] sample: (&str, &str, &[&str]), + #[values("/src/item3.ts", "/src/item3.css.ts")] filename: &str, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + css::debug::set_debug(true); + let (binding, expression, expected) = sample; + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{binding}\nexport const card = {expression};" + ); + // When + let output = extract_with_modules( + filename, + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &memory_resolver(MODULES), + ) + .unwrap_or_else(|error| panic!("{source}\n{error}")); + // Then: card is a string whose selected tokens link to CSS-generator atoms. + let aliases_base = + expression == "css`${base};`" && output.code.contains("export const card = base;"); + let selected = if aliases_base { + output.code.replace("const base =", "export const base =") + } else { + output.code.clone() + }; + let export = if aliases_base { "base" } else { "card" }; + assert_composed_export_atoms(&selected, export, expected); + let declaration = format!("export const {export} = \""); + let classes = selected + .split_once(&declaration) + .unwrap() + .1 + .split_once('"') + .unwrap() + .0; + for class in classes.split_whitespace() { + assert!(output.styles.iter().any(|style| { + matches!(style.extract((!single_css).then_some(filename)), Some(StyleProperty::ClassName(name)) if name == class) + }), "selected class {class} has no emitted style: {output:?}"); + } + if binding.starts_with("import ") { + assert!( + output + .dependencies + .iter() + .any(|path| path == "/src/rules.ts") + ); + } +} + +#[rstest] +#[serial] +fn opaque_class_survives_when_an_object_statement_is_composed( + #[values(false, true)] single_css: bool, +) { + // Given: runtime class input must stay opaque, unlike the known style record. + let _state = crate::test_state::TestStateGuard::new(); + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{RULES}\nfunction render(externalClass) {{ const card = css`${{externalClass}}; ${{rules}};`; return {{ props: {{ className: card }} }}; }}" + ); + // When: execute the actual generated merge through the existing observer. + let output = extract( + "/src/opaque.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + let observed = render_observation( + &output, + "(() => { const result = render('external'); const card = result.props.className; if (typeof card !== 'string' || card.split(/\\s+/).filter(x => x === 'external').length !== 1 || card.includes('[object Object]')) throw Error('invalid card'); return result; })()", + (!single_css).then_some("/src/opaque.ts"), + ); + // Then: blue belongs to a selected class, not merely an unused sheet atom. + assert_eq!(observed, r#"[[],["blue"]]"#); + assert_eq!(output.styles.len(), 1); + let ExtractStyleValue::Static(style) = output.styles.iter().next().unwrap() else { + panic!("expected a static hover declaration: {output:?}"); + }; + assert_eq!(style.selector(), Some(&"&:hover".into())); + assert_eq!(style.level(), 0); + assert_eq!(style.style_order(), None); +} + +#[rstest] +#[case::changed("rules.selectors['&:hover'].color = 'green';")] +#[case::changed_alias("const alias = rules; alias.selectors['&:hover'].color = 'green';")] +#[case::escaped("register(rules);")] +#[serial] +fn changed_record_is_rejected_when_used_as_a_tag_statement( + #[case] change: &str, + #[values(false, true)] single_css: bool, +) { + // Given: only the ordinary route promises static mutation/escape refusal. + let _state = crate::test_state::TestStateGuard::new(); + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{RULES}\n{change}\nexport const card = css`${{rules}}`;" + ); + // When + let error = extract( + "/src/changed.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_err() + .to_string(); + // Then: point at the authored statement read, never synthetic/inlined text. + assert!(error.starts_with("/src/changed.ts:4:27: "), "{error}"); + assert!( + error.contains("at build time") && error.contains("rules"), + "{error}" + ); +} + +#[rstest] +#[case::unresolved_leaf(("css`${{ selectors: { '&:hover': { color: globalThis.tone } } }}`", "globalThis.tone"))] +#[case::unresolved_record(("css`${rules}`", "rules"))] +#[case::value_slot(("css`color: ${rules};`", "rules"))] +#[case::property_slot(("css`${rules}: red;`", "rules"))] +#[serial] +fn object_leaves_and_nonstatement_slots_retain_authored_errors( + #[case] sample: (&str, &str), + #[values(false, true)] single_css: bool, +) { + // Given: unresolved records stay distinct from legitimate opaque classes. + let _state = crate::test_state::TestStateGuard::new(); + let (expression, leaf) = sample; + let binding = if expression == "css`${rules}`" { + "const rules = { selectors: { '&:hover': { color: globalThis.tone } } };" + } else { + RULES + }; + let source = format!( + "import {{ css }} from '@devup-ui/react';\n{binding}\nexport const card = {expression};" + ); + let line = source.lines().nth(2).unwrap(); + let column = if expression == "css`color: ${rules};`" { + 21 + } else { + line.find(leaf).unwrap() + 1 + }; + // When + let error = extract_with_modules( + "/src/refused.ts", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + false, + &memory_resolver(&[]), + ) + .unwrap_err() + .to_string(); + // Then: no object-to-rules expansion in CSS value/property positions. + assert!( + error.starts_with(&format!("/src/refused.ts:3:{column}: ")), + "{error}" + ); + assert!(error.contains("at build time"), "{error}"); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 17398d3d8..35b2b2ab8 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -6,9 +6,7 @@ use crate::{ extract_static_style::{ExtractStaticStyle, ThemeTokenResolution}, extract_style_value::ExtractStyleValue, }, - extractor::{ - ExtractResult, extract_style_from_member_expression::extract_style_from_member_expression, - }, + extractor::{ExtractResult, extract_style_from_member_expression::extract_member}, utils::{ CSS_TEXT, RESPONSIVE_ARRAY, SELECTOR_NAME, expression_to_code, get_number_by_literal_expression, get_str_by_property_key, @@ -230,9 +228,29 @@ pub fn extract_style_from_expression<'a>( level: u8, selector: &Option, literal_handling: LiteralHandling, +) -> ExtractResult<'a> { + extract_with_policy::( + ast_builder, + name, + expression, + level, + selector, + literal_handling, + ) +} + +/// Extract styles, optionally retaining authored locations of dynamic object leaves. +pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( + ast_builder: &AstBuilder<'a>, + name: Option<&str>, + expression: &mut Expression<'a>, + level: u8, + selector: &Option, + literal_handling: LiteralHandling, ) -> ExtractResult<'a> { let mut typo = false; let expression = unwrap_syntax_only_mut(expression); + let offset = expression.span().start; if name.is_none() { let mut styles = crate::dead_properties::expression_errors(expression); @@ -289,7 +307,7 @@ pub fn extract_style_from_expression<'a>( } else { let ExtractResult { styles, tag: _tag, .. - } = extract_style_from_expression( + } = extract_with_policy::( ast_builder, Some(disassembled), &mut prop.value, @@ -309,7 +327,7 @@ pub fn extract_style_from_expression<'a>( ObjectPropertyKind::SpreadProperty(prop) => { let ExtractResult { styles, tag: _tag, .. - } = extract_style_from_expression( + } = extract_with_policy::( ast_builder, None, &mut prop.argument, @@ -338,7 +356,7 @@ pub fn extract_style_from_expression<'a>( styles: vec![ExtractStyleProp::Conditional { condition: conditional.test.clone_in(ast_builder.allocator()), consequent: Some(Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, None, &mut conditional.consequent, @@ -349,7 +367,7 @@ pub fn extract_style_from_expression<'a>( .styles, ))), alternate: Some(Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, None, &mut conditional.alternate, @@ -388,7 +406,7 @@ pub fn extract_style_from_expression<'a>( let offset = expression.span().start; let mut styles = vec![]; for part in split_selector_list(name) { - styles.extend(extract_under_key( + styles.extend(extract_under_key::( ast_builder, part, offset, @@ -433,7 +451,7 @@ pub fn extract_style_from_expression<'a>( }; let offset = o.key.span().start; for part in split_selector_list(key_name.trim()) { - props.extend(extract_under_key( + props.extend(extract_under_key::( ast_builder, part, offset, @@ -460,7 +478,7 @@ pub fn extract_style_from_expression<'a>( if let ObjectPropertyKind::ObjectProperty(o) = p && let Some(layer) = get_string_by_property_key(&o.key) { - let mut styles = extract_style_from_expression( + let mut styles = extract_with_policy::( ast_builder, None, &mut o.value, @@ -490,7 +508,7 @@ pub fn extract_style_from_expression<'a>( StyleSelector::nest_at_rule(selector.as_ref(), kind, &query) { props.extend( - extract_style_from_expression( + extract_with_policy::( ast_builder, None, &mut o.value, @@ -511,7 +529,7 @@ pub fn extract_style_from_expression<'a>( if name.starts_with('_') || split_at_rule_key(name).is_some() { let offset = expression.span().start; return ExtractResult { - styles: extract_under_key( + styles: extract_under_key::( ast_builder, name, offset, @@ -525,7 +543,7 @@ pub fn extract_style_from_expression<'a>( } typo = name == "typography"; } - if let Some(value) = get_string_by_literal_expression(expression) { + let mut result = if let Some(value) = get_string_by_literal_expression(expression) { if let Some(name) = name { ExtractResult { styles: if typo { @@ -638,7 +656,7 @@ pub fn extract_style_from_expression<'a>( None => unreadable(expression), }, Expression::ComputedMemberExpression(mem) => { - extract_style_from_member_expression(ast_builder, name, mem, level, selector) + extract_member::(ast_builder, name, mem, level, selector) } Expression::TemplateLiteral(_) => { let Some(name) = name else { @@ -788,7 +806,7 @@ pub fn extract_style_from_expression<'a>( LogicalOperator::Or => literal_truthiness(&logical.left) == Some(false), LogicalOperator::Coalesce => nullish, }; - extract_style_from_expression( + extract_with_policy::( ast_builder, name, if takes_right { @@ -805,7 +823,7 @@ pub fn extract_style_from_expression<'a>( if literal_truthiness(&conditional.test).is_some() => { let consequent = literal_truthiness(&conditional.test) == Some(true); - extract_style_from_expression( + extract_with_policy::( ast_builder, name, if consequent { @@ -820,7 +838,7 @@ pub fn extract_style_from_expression<'a>( } Expression::LogicalExpression(logical) => { let res = Some(Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, name, &mut logical.right, @@ -869,7 +887,7 @@ pub fn extract_style_from_expression<'a>( ast_builder, ), consequent: Some(Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, name, &mut logical.left, @@ -901,7 +919,7 @@ pub fn extract_style_from_expression<'a>( for (idx, element) in array.elements.iter_mut().enumerate() { if let Some(element) = element.as_expression_mut() { props.extend( - extract_style_from_expression( + extract_with_policy::( ast_builder, name, element, @@ -923,7 +941,7 @@ pub fn extract_style_from_expression<'a>( } Expression::ConditionalExpression(conditional) => { if is_same_expression(&conditional.consequent, &conditional.alternate) { - extract_style_from_expression( + extract_with_policy::( ast_builder, name, &mut conditional.consequent, @@ -936,7 +954,7 @@ pub fn extract_style_from_expression<'a>( styles: vec![ExtractStyleProp::Conditional { condition: conditional.test.clone_in(ast_builder.allocator()), consequent: Some(Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, name, &mut conditional.consequent, @@ -947,7 +965,7 @@ pub fn extract_style_from_expression<'a>( .styles, ))), alternate: Some(Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, name, &mut conditional.alternate, @@ -1013,7 +1031,7 @@ pub fn extract_style_from_expression<'a>( ObjectPropertyKind::ObjectProperty(o) => o, ObjectPropertyKind::SpreadProperty(spread) => { props.extend( - extract_style_from_expression( + extract_with_policy::( ast_builder, None, &mut spread.argument, @@ -1043,7 +1061,7 @@ pub fn extract_style_from_expression<'a>( for name in disassemble_property(&key_name) { let name: &str = &name; props.extend( - extract_style_from_expression( + extract_with_policy::( ast_builder, Some(name), &mut o.value, @@ -1103,6 +1121,24 @@ pub fn extract_style_from_expression<'a>( }, }, } + }; + if LOCATE { + for style in &mut result.styles { + locate_dynamic(style, offset); + } + } + result +} + +/// Locate only an immediate normalized dynamic leaf; recursive extraction owns children. +pub(super) fn locate_dynamic(style: &mut ExtractStyleProp<'_>, offset: u32) { + if let ExtractStyleProp::Static(ExtractStyleValue::Dynamic(value)) = style { + *style = ExtractStyleProp::Unreadable { + offset, + code: std::mem::take(value.identifier_mut()), + prop: false, + requirement: None, + }; } } @@ -1297,7 +1333,7 @@ impl From> for Nested { } } -fn extract_under_key<'a>( +fn extract_under_key<'a, const LOCATE: bool>( ast_builder: &AstBuilder<'a>, key: &str, offset: u32, @@ -1308,7 +1344,7 @@ fn extract_under_key<'a>( ) -> Vec> { match nest_selectors_key(selector.as_ref(), key) { Nested::Under(nested) => { - extract_style_from_expression( + extract_with_policy::( ast_builder, None, expression, diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index 5727af208..2e2a2ab41 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -3,7 +3,7 @@ use crate::{ extractor::{ ExtractResult, extract_style_from_expression::{ - LiteralHandling, dynamic_style, extract_style_from_expression, + LiteralHandling, dynamic_style, extract_with_policy, locate_dynamic, }, }, utils::{ @@ -20,7 +20,7 @@ use oxc_ast::{ use oxc_span::SPAN; use std::collections::BTreeMap; -pub(super) fn extract_style_from_member_expression<'a>( +pub(super) fn extract_member<'a, const LOCATE: bool>( ast_builder: &AstBuilder<'a>, name: Option<&str>, mem: &mut ComputedMemberExpression<'a>, @@ -58,7 +58,7 @@ pub(super) fn extract_style_from_member_expression<'a>( } .then(|| mem.object.clone_in(ast_builder.allocator())); let runtime = |whole: &Expression<'a>, offset: u32| { - runtime_member( + runtime_member::( ast_builder, name, whole.clone_in(ast_builder.allocator()), @@ -85,7 +85,7 @@ pub(super) fn extract_style_from_member_expression<'a>( if Some(idx) == selected_index && let Some(p) = p.as_expression_mut() { - return extract_style_from_expression( + return extract_with_policy::( ast_builder, name, p, @@ -114,7 +114,7 @@ pub(super) fn extract_style_from_member_expression<'a>( map.insert( idx.to_string(), Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, name, p, @@ -157,7 +157,7 @@ pub(super) fn extract_style_from_member_expression<'a>( && get_str_by_property_key(&o.key).as_deref() == Some(k.as_ref()) { return ExtractResult { - styles: extract_style_from_expression( + styles: extract_with_policy::( ast_builder, name, &mut o.value, @@ -180,7 +180,7 @@ pub(super) fn extract_style_from_member_expression<'a>( map.insert( property_name, Box::new(ExtractStyleProp::StaticArray( - extract_style_from_expression( + extract_with_policy::( ast_builder, name, &mut o.value, @@ -201,7 +201,7 @@ pub(super) fn extract_style_from_member_expression<'a>( &mem.object, Expression::ArrayExpression(_) | Expression::ObjectExpression(_) ) { - ret.push(runtime_member( + ret.push(runtime_member::( ast_builder, name, mem.object.clone_in(ast_builder.allocator()), @@ -220,7 +220,7 @@ pub(super) fn extract_style_from_member_expression<'a>( /// `object[key]` known only at runtime: a CSS variable for a property, and /// under a selector, which takes styles, what the build cannot read -fn runtime_member<'a>( +fn runtime_member<'a, const LOCATE: bool>( ast_builder: &AstBuilder<'a>, name: Option<&str>, object: Expression<'a>, @@ -236,7 +236,7 @@ fn runtime_member<'a>( false, ast_builder, )); - match name { + let mut style = match name { Some(name) => dynamic_style(ast_builder, name, &member, level, selector), None => ExtractStyleProp::Unreadable { offset, @@ -244,5 +244,9 @@ fn runtime_member<'a>( prop: false, requirement: None, }, + }; + if LOCATE { + locate_dynamic(&mut style, offset); } + style } diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 062a46272..5c69a2041 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}; +#[cfg(test)] +#[path = "imported_constants_px_tests.rs"] +mod px_tests; + #[derive(Clone, Debug)] enum Constant { String(String), @@ -395,6 +399,9 @@ pub(crate) fn inline_constants<'a>( style_roots.insert(local); match specifier { ImportDeclarationSpecifier::ImportSpecifier(specifier) => { + if !stylex && specifier.imported.name() == "css" { + apis.css.insert(local); + } if takes_style_objects(stylex, &specifier.imported.name()) { apis.functions.insert(local); } @@ -587,6 +594,7 @@ pub(crate) fn inline_constants<'a>( css_props: &css_props, apis: &apis, css_failures: &mut inlined.css_failures, + unknown: &mut inlined.unknown, objects: false, styles: false, px: false, @@ -678,6 +686,7 @@ impl<'p, 'a, 'r> ChangeCheck<'p, 'a, 'r> { #[derive(Default)] struct StyleApis<'s> { functions: FxHashSet<&'s str>, + css: FxHashSet<&'s str>, /// Namespace and default imports, `true` for `StyleX` namespaces: FxHashMap<&'s str, bool>, } @@ -1986,6 +1995,7 @@ struct Inline<'s, 'a> { css_props: &'s CssTakers<'s>, apis: &'s StyleApis<'s>, css_failures: &'s mut Vec, + unknown: &'s mut Unknown, /// Inside what the build reads as style objects objects: bool, /// Inside the arguments of a style API or a style prop @@ -2334,8 +2344,71 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { &tagged.tag, self.css_props.has_class_names().then_some(self.scoping), ); + let css = is_style_root(self.style_roots, &tagged.tag, Some(self.scoping)) + && match &tagged.tag { + Expression::Identifier(name) => self.apis.css.contains(name.name.as_str()), + Expression::StaticMemberExpression(member) => { + member.property.name == "css" + && matches!(&member.object, Expression::Identifier(name) + if self.apis.namespaces.get(name.name.as_str()) == Some(&false)) + } + _ => false, + }; + if !css { + self.reading_styles(styles, |inline| { + inline.visit_template_literal(&mut tagged.quasi); + }); + return; + } self.reading_styles(styles, |inline| { - inline.visit_template_literal(&mut tagged.quasi); + let mut before = None::; + for index in 0..tagged.quasi.expressions.len() { + let (prior, rest) = tagged.quasi.expressions.split_at_mut(index); + let expression = &mut rest[0]; + let value = inline.constant(expression); + let candidate = + matches!( + crate::utils::unwrap_syntax_only(expression), + Expression::ObjectExpression(_) + ) || matches!(value, Some(Constant::Object(_) | Constant::Record(_))); + if candidate && before.is_none() { + let mut text = String::new(); + for (quasi, expression) in tagged.quasi.quasis.iter().zip(prior) { + text.push_str(&quasi.value.raw); + if let Some(value) = + crate::utils::get_string_by_literal_expression(expression) + { + text.push_str(&value); + } + } + before = Some(text); + } + if let Some(before) = &mut before { + before.push_str(&tagged.quasi.quasis[index].value.raw); + } + let objects = candidate + && before.as_ref().is_some_and(|before| { + matches!( + crate::css_utils::interpolation_place( + before, + &tagged.quasi.quasis[index + 1..] + ), + crate::css_utils::Place::Statement + ) + }); + inline.reading_objects(objects, |inline| inline.visit_expression(expression)); + if objects + && value.is_some() + && let Some(name) = crate::utils::binding_root(expression) + { + inline.unknown.names.insert(name.to_string()); + } + if let Some(before) = &mut before + && let Some(text) = crate::utils::get_string_by_literal_expression(expression) + { + before.push_str(&text); + } + } }); } diff --git a/libs/extractor/src/imported_constants_px_tests.rs b/libs/extractor/src/imported_constants_px_tests.rs new file mode 100644 index 000000000..052927ffa --- /dev/null +++ b/libs/extractor/src/imported_constants_px_tests.rs @@ -0,0 +1,83 @@ +use super::*; +use rstest::rstest; + +#[rstest] +fn own_css_record_honors_authored_px_marks_when_inlined(#[values(false, true)] marked: bool) { + // Given: only the authored interpolation is marked, not its declaration. + let source = "import { css } from '@devup-ui/react'; +const rules = { padding: 2, p: 3, opacity: 0.5, margin: 0, vars: { '--gap': 7 } }; +export const card = css`${rules}`;"; + let allocator = Allocator::default(); + let mut program = crate::parsing::parse(&allocator, "/src/px.ts", source) + .unwrap_or_else(|error| panic!("invalid px fixture: {error}")); + let mut marks = crate::import_alias_visit::PxMarks::default(); + let start = u32::try_from( + source + .rfind("rules}") + .unwrap_or_else(|| panic!("missing authored interpolation")), + ) + .unwrap_or_else(|error| panic!("fixture offset exceeds u32: {error}")); + if marked { + marks.insert((start, start + 5)); + } + + // When: run the lower-level contract directly, without an alias transform. + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "/src/px.ts", + &ExtractOption::default(), + None, + CssProp::Off, + &marks, + ); + + // Then: marked lengths become px, but unitless, zero and vars stay numeric. + assert_eq!(inlined.css_failures.len(), 0); + let Some(Statement::ExportDeclaration(export)) = program.body.last() else { + panic!("expected exported card"); + }; + let oxc_ast::ast::Declaration::VariableDeclaration(declaration) = &export.declaration else { + panic!("expected card declaration"); + }; + let Some(Expression::TaggedTemplateExpression(tagged)) = + declaration.declarations[0].init.as_ref() + else { + panic!("expected own css tag"); + }; + let Expression::ObjectExpression(object) = &tagged.quasi.expressions[0] else { + panic!("expected materialized record"); + }; + for (property, (key, number)) in object.properties.iter().zip([ + ("padding", 2.0), + ("p", 3.0), + ("opacity", 0.5), + ("margin", 0.0), + ("vars", 7.0), + ]) { + let ObjectPropertyKind::ObjectProperty(property) = property else { + panic!("expected record property"); + }; + assert_eq!(property.key.static_name().as_deref(), Some(key)); + let value = if key == "vars" { + let Expression::ObjectExpression(vars) = &property.value else { + panic!("expected vars record"); + }; + let ObjectPropertyKind::ObjectProperty(variable) = &vars.properties[0] else { + panic!("expected custom property"); + }; + &variable.value + } else { + &property.value + }; + if marked && key == "padding" { + let Expression::StringLiteral(value) = value else { + panic!("expected marked length in px, got {value:?}"); + }; + assert_eq!(value.value, "2px"); + } else { + assert_eq!(crate::utils::js_number_literal(value), Some(number)); + } + } + assert_eq!(object.properties.len(), 5); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 3cbee94f1..07757dcfe 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1031,6 +1031,15 @@ mod tests { mod styled_transient_tests; mod exact_literal_coercion { + mod const_object_css_tag { + include!("const_object_css_tag_tests.rs"); + } + mod const_object_css_tag_controls { + include!("const_object_css_tag_controls_tests.rs"); + } + mod const_object_css_tag_policy { + include!("const_object_css_tag_policy_tests.rs"); + } include!("stylex_exact_key_tests.rs"); use super::*; diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 8fc5e94dc..fc9d1de31 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -23,7 +23,7 @@ use crate::extractor::{ ExtractResult, GlobalExtractResult, extract_global_style_from_expression::extract_global_style_from_expression, extract_style_from_expression::{ - LiteralHandling, extract_style_from_expression, flatten_spreads, + LiteralHandling, extract_style_from_expression, extract_with_policy, flatten_spreads, }, extract_style_from_jsx::extract_style_from_jsx, extract_style_from_styled::{ @@ -503,7 +503,7 @@ impl<'a> DevupVisitor<'a> { } self.unknown_arguments("css", &call.arguments); self.changed_arguments("css", &call.arguments); - let (result, known) = self.composed_class(call.span.start, parts); + let (result, known) = self.composed_class::(call.span.start, parts); if let Some(known) = known { self.css_styles = Some((call.span.start, known)); } @@ -585,12 +585,37 @@ impl<'a> DevupVisitor<'a> { tag: &oxc_ast::ast::TaggedTemplateExpression<'a>, flattened: bool, ) -> Option> { + if !flattened + && !tag.quasi.expressions.iter().any(|expression| { + matches!( + unwrap_syntax_only(expression), + Expression::ObjectExpression(_) + ) || self.reads_known_styles(expression) + || (!self.unknown_bindings.is_empty() + && reads_unknown(expression, &self.unknown_bindings)) + || (!self.changed_bindings.is_empty() + && reads_binding(expression, &self.changed_bindings)) + }) + { + return None; + } let texts = template_parts(&self.ast, &tag.quasi, true).ok()?; let is_mixin = |text: &Expression<'a>| !matches!(text, Expression::TemplateLiteral(_)); - if !texts - .iter() - .any(|text| is_mixin(text) && (flattened || self.reads_known_styles(text))) - { + if !self.unknown_bindings.is_empty() || !self.changed_bindings.is_empty() { + let arguments: Vec> = texts + .iter() + .filter(|text| is_mixin(text)) + .map(|text| text.clone_in_with_semantic_ids(self.ast.allocator()).into()) + .collect(); + self.unknown_arguments("css", &arguments); + self.changed_arguments("css", &arguments); + } + if !texts.iter().any(|text| { + is_mixin(text) + && (flattened + || self.reads_known_styles(text) + || matches!(unwrap_syntax_only(text), Expression::ObjectExpression(_))) + }) { return None; } let mut parts = Vec::new(); @@ -603,7 +628,13 @@ impl<'a> DevupVisitor<'a> { self.known_parts(text, &mut parts, kind)?; } let offset = tag.span.start; - let (result, known) = self.composed_class(offset, parts); + let arguments: Vec> = texts.into_iter().map(Into::into).collect(); + self.style_values + .record_rules(&self.ast, offset, &arguments); + let (mut result, known) = self.composed_class::(offset, parts); + if let Expression::StringLiteral(literal) = &mut result { + literal.span = tag.span; + } if let Some(known) = known { self.css_styles = Some((offset, known)); } @@ -612,7 +643,7 @@ impl<'a> DevupVisitor<'a> { /// The classes `parts`, composed at `offset`, give, with the styles they /// always set when no class the build does not know joins them - fn composed_class( + fn composed_class( &mut self, offset: u32, parts: Vec>, @@ -622,7 +653,7 @@ impl<'a> DevupVisitor<'a> { for part in parts { match part { KnownPart::Styles(side) => { - let props = self.part_props(offset, side, None); + let props = self.part_props::(offset, side, None); composition.apply(&self.ast, props); } KnownPart::Conditional { @@ -630,8 +661,8 @@ impl<'a> DevupVisitor<'a> { consequent, alternate, } => { - let consequent = self.part_props(offset, consequent, None); - let alternate = self.part_props(offset, alternate, None); + let consequent = self.part_props::(offset, consequent, None); + let alternate = self.part_props::(offset, alternate, None); composition.apply_conditional(&self.ast, &test, consequent, alternate); } KnownPart::Class(mut class) => { @@ -864,7 +895,7 @@ impl<'a> DevupVisitor<'a> { /// The styles of a part, each at the order the part gives it; `element` /// names the element taking them as its `css` prop, which sets a value /// only the runtime gives as a CSS variable - fn part_props( + fn part_props( &mut self, offset: u32, styles: Vec>, @@ -882,7 +913,7 @@ impl<'a> DevupVisitor<'a> { mut styles, style_order, .. - } = extract_style_from_expression( + } = extract_with_policy::( &self.ast, None, &mut rules, @@ -1106,12 +1137,8 @@ impl<'a> DevupVisitor<'a> { fn util_type(&self, callee: &Expression<'a>) -> Option> { match callee { Expression::Identifier(ident) => { - if !self.class_names_components.is_empty() - && !self.style_values.reads_root_binding(ident) - { - return None; - } - self.util_imports.get(ident.name.as_str()) + let util = self.util_imports.get(ident.name.as_str())?; + self.style_values.reads_root_binding(ident).then_some(util) } Expression::StaticMemberExpression(member) if !self.util_imports.is_empty() => { let Expression::Identifier(ident) = &member.object else { @@ -1123,7 +1150,8 @@ impl<'a> DevupVisitor<'a> { key.push_str(obj); key.push('.'); key.push_str(prop); - self.util_imports.get(key.as_str()) + let util = self.util_imports.get(key.as_str())?; + self.style_values.reads_root_binding(ident).then_some(util) } _ => None, } @@ -1535,7 +1563,7 @@ impl<'a> DevupVisitor<'a> { for part in parts { match part { KnownPart::Styles(side) => { - let props = self.part_props(offset, side, Some(element)); + let props = self.part_props::(offset, side, Some(element)); composition.apply(&self.ast, props); } KnownPart::Conditional { @@ -1543,8 +1571,8 @@ impl<'a> DevupVisitor<'a> { consequent, alternate, } => { - let consequent = self.part_props(offset, consequent, Some(element)); - let alternate = self.part_props(offset, alternate, Some(element)); + let consequent = self.part_props::(offset, consequent, Some(element)); + let alternate = self.part_props::(offset, alternate, Some(element)); composition.apply_conditional(&self.ast, &test, consequent, alternate); } KnownPart::Class(mut class) => { @@ -1920,7 +1948,7 @@ impl<'a> DevupVisitor<'a> { self.changed_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 + self.composed_class::(offset, parts).0 } else { self.errors.push(( offset, From f42c18ada555ca1af8efb0bdcf650f3b928217ec Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Oct 2026 12:13:31 +0900 Subject: [PATCH 2/3] fix(extractor): preserve bindings through prop clones Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../src/ci_factory_alias_active_tests.rs | 26 +++ .../src/ci_factory_alias_native_tests.rs | 140 +++++++++++++++ .../src/ci_factory_alias_spread_tests.rs | 165 ++++++++++++++++++ .../ci_factory_alias_style_support_tests.rs | 107 ++++++++++++ .../src/ci_factory_alias_style_tests.rs | 86 +++++++++ .../src/ci_factory_alias_support_tests.rs | 78 +++++++++ libs/extractor/src/ci_factory_alias_tests.rs | 58 ++++++ .../extract_style_from_expression.rs | 9 +- .../src/imported_constants_px_tests.rs | 72 ++++++++ libs/extractor/src/lib.rs | 3 + libs/extractor/src/prop_modify_utils.rs | 47 +++-- libs/extractor/src/utils.rs | 4 +- libs/extractor/src/visit.rs | 2 +- 13 files changed, 774 insertions(+), 23 deletions(-) create mode 100644 libs/extractor/src/ci_factory_alias_active_tests.rs create mode 100644 libs/extractor/src/ci_factory_alias_native_tests.rs create mode 100644 libs/extractor/src/ci_factory_alias_spread_tests.rs create mode 100644 libs/extractor/src/ci_factory_alias_style_support_tests.rs create mode 100644 libs/extractor/src/ci_factory_alias_style_tests.rs create mode 100644 libs/extractor/src/ci_factory_alias_support_tests.rs create mode 100644 libs/extractor/src/ci_factory_alias_tests.rs diff --git a/libs/extractor/src/ci_factory_alias_active_tests.rs b/libs/extractor/src/ci_factory_alias_active_tests.rs new file mode 100644 index 000000000..0a9735d31 --- /dev/null +++ b/libs/extractor/src/ci_factory_alias_active_tests.rs @@ -0,0 +1,26 @@ +use super::*; + +#[rstest] +#[case::direct("className: OWN, css: { color: 'red' }")] +#[case::template("className: `external ${OWN}`, css: { color: 'red' }")] +#[serial] +fn active_native_css_prop_merges_class_aliases_with_emitted_css( + #[case] fragment: &str, + #[values(Binding::Alias, Binding::Namespace, Binding::ShadowAlias, Binding::ShadowNamespace)] binding: Binding, + #[values(false, true)] single_css: bool, +) { + // Given: pragma and alias jointly enable css-prop lowering (v2 evidence). + let _state = crate::test_state::TestStateGuard::new(); + let props = fragment.replace("OWN", binding.call()); + let source = format!("/** @jsxImportSource @emotion/react */\n{} import {{ jsx as o }} from 'react/jsx-runtime'; function render({}) {{ return o('div', {{ {props} }}); }}", binding.imports(), binding.parameter()); + // When + let output = extract_fixture(&source, single_css, true); + // Then: red belongs to css-prop; hidden must never come from a lexical shadow. + assert_own_styles(&output, binding); + let invocation = format!("(() => {{ const result = render({}); const tokens = (result.props.className || '').split(/\\s+/); trace.push('css:' + ('css' in result.props), 'external:' + tokens.includes('external'), 'local:' + tokens.includes('local')); return result; }})()", binding.argument(true)); + let mut trace = Vec::new(); + if binding.shadow() { trace.push("own:hidden".to_string()); } + trace.extend(["css:false".to_string(), format!("external:{}", fragment.contains("external")), format!("local:{}", binding.shadow())]); + let expected = if binding.shadow() { vec!["red".to_string()] } else { vec!["hidden".to_string(), "red".to_string()] }; + assert_eq!(observe(&output, &invocation, single_css), (trace, expected)); +} diff --git a/libs/extractor/src/ci_factory_alias_native_tests.rs b/libs/extractor/src/ci_factory_alias_native_tests.rs new file mode 100644 index 000000000..07a021e2c --- /dev/null +++ b/libs/extractor/src/ci_factory_alias_native_tests.rs @@ -0,0 +1,140 @@ +include!("ci_factory_alias_support_tests.rs"); + +mod spreads { + include!("ci_factory_alias_spread_tests.rs"); +} +mod active_css { + include!("ci_factory_alias_active_tests.rs"); +} + +#[derive(Clone, Copy, Debug)] +enum Shape { + Direct, + DirectMerged, + Conditional, + Template, + NestedTemplate, + TailwindConditional, + TailwindTest, + Logical, + Call, + Array, + Object, + TailwindTemplate, + StyleOrder, +} + +impl Shape { + fn props(self) -> &'static str { + match self { + Self::Direct => "className: OWN", + Self::DirectMerged => "className: OWN, color: 'red'", + Self::Conditional => "className: h ? OWN : void 0", + Self::Template => "className: `external ${OWN}`", + Self::NestedTemplate => "className: `external ${h ? OWN : ''}`", + Self::TailwindConditional => "className: h ? 'flex' : OWN", + Self::TailwindTest => "className: OWN ? 'flex' : 'external'", + Self::Logical => "className: OWN || 'flex'", + Self::Call => "className: clsx('flex', OWN)", + Self::Array => "className: ['flex', OWN].join(' ')", + Self::Object => "className: clsx({ 'flex': OWN })", + Self::TailwindTemplate => "className: `flex ${OWN}`", + Self::StyleOrder => "className: OWN, color: 'red', styleOrder: h ? 1 : 0", + } + } +} + +#[rstest] +#[case::direct(Shape::Direct)] +#[case::direct_merged(Shape::DirectMerged)] +#[case::conditional(Shape::Conditional)] +#[case::template(Shape::Template)] +#[case::nested_template(Shape::NestedTemplate)] +#[case::tailwind_conditional(Shape::TailwindConditional)] +#[case::tailwind_test(Shape::TailwindTest)] +#[case::logical(Shape::Logical)] +#[case::call(Shape::Call)] +#[case::array(Shape::Array)] +#[case::object(Shape::Object)] +#[case::tailwind_template(Shape::TailwindTemplate)] +#[case::style_order(Shape::StyleOrder)] +#[serial] +fn class_aliases_select_emitted_styles_when_preprocessing_retains_bindings( + #[case] shape: Shape, + #[values( + Binding::Alias, + Binding::Namespace, + Binding::ShadowAlias, + Binding::ShadowNamespace + )] + binding: Binding, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let props = shape.props().replace("OWN", binding.call()); + let source = format!( + "{} import {{ jsx as o }} from 'react/jsx-runtime'; function render(h, {}, clsx) {{ return o(t, {{ {props} }}); }}", + binding.imports(), + binding.parameter() + ); + // When + let output = extract_fixture(&source, single_css, false); + // Then: selected classes, not merely generated-but-unused rules, decide styles. + assert_own_styles(&output, binding); + for h in [false, true] { + let truthy = !binding.shadow() || h; + let called = match shape { + Shape::Conditional | Shape::NestedTemplate => h, + Shape::TailwindConditional => !h, + _ => true, + }; + let selected = match shape { + Shape::TailwindTest | Shape::Object => false, + _ => called, + }; + let flex = match shape { + Shape::TailwindConditional => h, + Shape::TailwindTest | Shape::Object => truthy, + Shape::Logical => !truthy, + Shape::Call | Shape::Array | Shape::TailwindTemplate => true, + _ => false, + }; + let external = matches!(shape, Shape::Template | Shape::NestedTemplate) + || (matches!(shape, Shape::TailwindTest) && !truthy); + let local = binding.shadow() && h && selected; + let invocation = format!( + "(() => {{ const result = render({h}, {}, {CLSX}); const tokens = (result.props.className || '').split(/\\s+/); trace.push('external:' + tokens.includes('external'), 'local:' + tokens.includes('local')); return result; }})()", + binding.argument(h) + ); + let mut expected_trace = Vec::new(); + if binding.shadow() && called { + expected_trace.push("own:hidden".to_string()); + } + if matches!(shape, Shape::Call | Shape::Object) { + expected_trace.push("clsx".to_string()); + } + expected_trace.extend([format!("external:{external}"), format!("local:{local}")]); + let mut expected = Vec::new(); + if selected && !binding.shadow() { + expected.push("hidden".to_string()); + } + if flex { + expected.push("flex".to_string()); + } + if matches!(shape, Shape::DirectMerged | Shape::StyleOrder) { + expected.push("red".to_string()); + } + expected.sort(); + assert_eq!( + observe(&output, &invocation, single_css), + (expected_trace, expected), + "{shape:?}/{binding:?}/h={h}\n{}", + output.code + ); + } +} + +mod style_metadata { + include!("ci_factory_alias_style_tests.rs"); +} diff --git a/libs/extractor/src/ci_factory_alias_spread_tests.rs b/libs/extractor/src/ci_factory_alias_spread_tests.rs new file mode 100644 index 000000000..c03ad65f3 --- /dev/null +++ b/libs/extractor/src/ci_factory_alias_spread_tests.rs @@ -0,0 +1,165 @@ +use super::*; + +#[rstest] +#[case::effectful_after_alias(("className: OWN, ...getProps()", false, true), Binding::Alias)] +#[case::effectful_after_namespace(("className: OWN, ...getProps()", false, true), Binding::Namespace)] +#[case::identifier_after_alias(("className: OWN, ...p", false, false), Binding::Alias)] +#[case::identifier_after_namespace(("className: OWN, ...p", false, false), Binding::Namespace)] +#[case::effectful_before_alias(("...getProps(), className: OWN", true, true), Binding::Alias)] +#[case::effectful_before_namespace(("...getProps(), className: OWN", true, true), Binding::Namespace)] +#[case::effectful_before_shadow_alias(("...getProps(), className: OWN", true, true), Binding::ShadowAlias)] +#[case::effectful_before_shadow_namespace(("...getProps(), className: OWN", true, true), Binding::ShadowNamespace)] +#[case::literal_alias(("...{ className: OWN }", true, false), Binding::Alias)] +#[case::literal_namespace(("...{ className: OWN }", true, false), Binding::Namespace)] +#[case::literal_shadow_alias(("...{ className: OWN }", true, false), Binding::ShadowAlias)] +#[case::literal_shadow_namespace(("...{ className: OWN }", true, false), Binding::ShadowNamespace)] +#[serial] +fn spread_aliases_preserve_class_presence_and_once_only_evaluation( + #[case] sample: (&str, bool, bool), + #[case] binding: Binding, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let (fragment, explicit_wins, effectful) = sample; + let props = fragment.replace("OWN", binding.call()); + let source = format!( + "{} import {{ jsx as o }} from 'react/jsx-runtime'; function render(p, getProps, {}) {{ return o(t, {{ {props} }}); }}", + binding.imports(), + binding.parameter() + ); + // When + let output = extract_fixture(&source, single_css, false); + // Then: an explicit undefined class differs from a missing class. + assert_own_styles(&output, binding); + for (spread, present, external) in [ + ("{}", false, false), + ("{className:'external'}", true, true), + ("{className:void 0}", true, false), + ] { + let own_selected = explicit_wins || !present; + let helper_first = fragment.starts_with("className"); + let invocation = format!( + "(() => {{ const result = render({spread}, () => {{ trace.push('spread'); return {spread}; }}, {}); const tokens = (result.props.className || '').split(/\\s+/); trace.push('external:' + tokens.includes('external'), 'local:' + tokens.includes('local')); return result; }})()", + binding.argument(true) + ); + let mut expected_trace = Vec::new(); + if binding.shadow() && helper_first { + expected_trace.push("own:hidden".to_string()); + } + if effectful { + expected_trace.push("spread".to_string()); + } + if binding.shadow() && !helper_first { + expected_trace.push("own:hidden".to_string()); + } + expected_trace.extend([ + format!("external:{}", !explicit_wins && external), + format!("local:{}", binding.shadow() && own_selected), + ]); + let expected = if own_selected && !binding.shadow() { + vec!["hidden".to_string()] + } else { + vec![] + }; + assert_eq!( + observe(&output, &invocation, single_css), + (expected_trace, expected), + "{fragment}/{binding:?}/{spread}\n{}", + output.code + ); + } +} + +#[rstest] +#[case::native("props: { className: OWN }")] +#[case::custom("as: Target, props: { className: OWN }")] +#[serial] +fn passthrough_class_aliases_retain_imports_without_compiling_local_helpers( + #[case] fragment: &str, + #[values( + Binding::Alias, + Binding::Namespace, + Binding::ShadowAlias, + Binding::ShadowNamespace + )] + binding: Binding, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let props = fragment.replace("OWN", binding.call()); + let source = format!( + "{} import {{ jsx as o }} from 'react/jsx-runtime'; function render(Target, {}) {{ return o(t, {{ {props} }}); }}", + binding.imports(), + binding.parameter() + ); + // When + let output = extract_fixture(&source, single_css, false); + // Then + assert_own_styles(&output, binding); + let invocation = format!( + "(() => {{ const result = render('article', {}); trace.push('target:' + result.target, 'local:' + (result.props.className || '').split(/\\s+/).includes('local')); return result; }})()", + binding.argument(true) + ); + let mut trace = Vec::new(); + if binding.shadow() { + trace.push("own:hidden".to_string()); + } + trace.push(format!( + "target:{}", + if fragment.starts_with("as:") { + "article" + } else { + "div" + } + )); + trace.push(format!("local:{}", binding.shadow())); + let expected = if binding.shadow() { + vec![] + } else { + vec!["hidden".to_string()] + }; + assert_eq!(observe(&output, &invocation, single_css), (trace, expected)); +} + +#[rstest] +#[serial] +fn spread_argument_aliases_are_moved_before_factory_body_rewriting( + #[values(false, true)] single_css: bool, + #[values( + Binding::Alias, + Binding::Namespace, + Binding::ShadowAlias, + Binding::ShadowNamespace + )] + binding: Binding, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let source = format!( + "{} import {{ jsx as o }} from 'react/jsx-runtime'; function render(getProps, {}) {{ return o(t, {{ ...getProps({}) }}); }}", + binding.imports(), + binding.parameter(), + binding.call() + ); + // When + let output = extract_fixture(&source, single_css, false); + // Then: getProps consumes the actual compiled class, not a copied expectation. + assert_own_styles(&output, binding); + let invocation = format!( + "render(className => {{ trace.push('spread'); return {{className}}; }}, {})", + binding.argument(true) + ); + let trace = if binding.shadow() { + vec!["own:hidden".to_string(), "spread".to_string()] + } else { + vec!["spread".to_string()] + }; + let expected = if binding.shadow() { + vec![] + } else { + vec!["hidden".to_string()] + }; + assert_eq!(observe(&output, &invocation, single_css), (trace, expected)); +} diff --git a/libs/extractor/src/ci_factory_alias_style_support_tests.rs b/libs/extractor/src/ci_factory_alias_style_support_tests.rs new file mode 100644 index 000000000..cd4049790 --- /dev/null +++ b/libs/extractor/src/ci_factory_alias_style_support_tests.rs @@ -0,0 +1,107 @@ +use super::*; + +#[derive(Clone, Copy, Debug)] +enum StyleShape { + Explicit, + Vars, + Combined, + ComputedValue, + ComputedKey, + ComputedBoth, +} + +impl StyleShape { + fn props(self, binding: Binding) -> String { + let value = binding.call(); + let key = value.replace("overflow: 'hidden'", "opacity: 0.5"); + match self { + Self::Explicit => format!("style: {{ '--own-class': {value} }}"), + Self::Vars => format!("styleVars: {{ ownClass: {value} }}"), + Self::Combined => { + format!("styleVars: {{ ownClass: {value} }}, style: {{ '--explicit-key': {key} }}") + } + Self::ComputedValue => format!("styleVars: {{ [h ? 'gap-a' : 'gap-b']: {value} }}"), + Self::ComputedKey => format!("styleVars: {{ [{key}]: 'fixed' }}"), + Self::ComputedBoth => format!("styleVars: {{ [{key}]: {value} }}"), + } + } + + fn computed_key(self) -> bool { + matches!(self, Self::ComputedKey | Self::ComputedBoth) + } + + fn key(self, h: bool) -> &'static str { + match self { + Self::Explicit => "--own-class", + Self::Vars | Self::Combined => "--ownClass", + Self::ComputedValue => { + if h { + "--gap-a" + } else { + "--gap-b" + } + } + Self::ComputedKey | Self::ComputedBoth => "--local-key", + } + } + + fn trace(self, binding: Binding) -> Vec { + if !binding.shadow() { + return vec![]; + } + match self { + Self::Explicit | Self::Vars | Self::ComputedValue => vec!["value:hidden".into()], + Self::Combined => vec!["value:hidden".into(), "key:0.5".into()], + Self::ComputedKey => vec!["key:0.5".into()], + Self::ComputedBoth => vec!["key:0.5".into(), "value:hidden".into()], + } + } +} + +fn style_argument(binding: Binding) -> &'static str { + const LOCAL: &str = "rules => { if ('opacity' in rules) { trace.push('key:' + rules.opacity); return 'local-key'; } trace.push('value:' + rules.overflow); return 'local-value'; }"; + match binding { + Binding::Alias | Binding::Namespace => "void 0", + Binding::ShadowAlias => LOCAL, + Binding::ShadowNamespace => { + "{ css: rules => { if ('opacity' in rules) { trace.push('key:' + rules.opacity); return 'local-key'; } trace.push('value:' + rules.overflow); return 'local-value'; } }" + } + } +} + +fn style_invocation(shape: StyleShape, binding: Binding, h: bool) -> String { + let expected_count = if matches!(shape, StyleShape::Combined) { + 2 + } else { + 1 + }; + let key_check = if shape.computed_key() && !binding.shadow() { + "keys[0].startsWith('--')".to_string() + } else { + format!("keys.includes({:?})", shape.key(h)) + }; + let value_check = match (shape, binding.shadow()) { + (StyleShape::ComputedKey, _) => "style[keys[0]] === 'fixed'", + (StyleShape::Combined, true) => { + "style['--ownClass'] === 'local-value' && style['--explicit-key'] === 'local-key'" + } + (_, true) => "style[keys[0]] === 'local-value'", + (_, false) => "typeof style[keys[0]] === 'string' && style[keys[0]].length > 0", + }; + format!( + "(() => {{ const result = render({h}, {}); const style = result.props.style; const keys = Object.keys(style); trace.push('target:' + result.target, 'count:' + keys.length, 'key:' + ({key_check}), 'value:' + ({value_check})); if (keys.length !== {expected_count}) throw Error('style entry count'); return result; }})()", + style_argument(binding) + ) +} + +fn held_projection(shape: StyleShape, binding: Binding, h: bool, key: bool) -> String { + let value = if key { + "Object.keys(style)[0].slice(2)".to_string() + } else { + format!("style[{:?}]", shape.key(h)) + }; + format!( + "(() => {{ const result = render({h}, {}); const style = result.props.style; return {{ props: {{ className: {value} }} }}; }})()", + style_argument(binding) + ) +} diff --git a/libs/extractor/src/ci_factory_alias_style_tests.rs b/libs/extractor/src/ci_factory_alias_style_tests.rs new file mode 100644 index 000000000..a82c99cf1 --- /dev/null +++ b/libs/extractor/src/ci_factory_alias_style_tests.rs @@ -0,0 +1,86 @@ +include!("ci_factory_alias_style_support_tests.rs"); + +#[rstest] +#[case::explicit(StyleShape::Explicit)] +#[case::vars(StyleShape::Vars)] +#[case::combined(StyleShape::Combined)] +#[case::computed_value(StyleShape::ComputedValue)] +#[case::computed_key(StyleShape::ComputedKey)] +#[case::computed_both(StyleShape::ComputedBoth)] +#[serial] +fn style_metadata_preserves_imported_calls_and_lexical_shadows( + #[case] shape: StyleShape, + #[values( + Binding::Alias, + Binding::Namespace, + Binding::ShadowAlias, + Binding::ShadowNamespace + )] + binding: Binding, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let props = shape.props(binding); + let source = format!( + "{} import {{ jsx as o }} from 'react/jsx-runtime'; function render(h, {}) {{ return o(t, {{ {props} }}); }}", + binding.imports(), + binding.parameter() + ); + // When + let output = extract_fixture(&source, single_css, false); + // Then + for h in [false, true] { + let mut expected_trace = shape.trace(binding); + expected_trace.extend([ + "target:div".into(), + format!( + "count:{}", + if matches!(shape, StyleShape::Combined) { + 2 + } else { + 1 + } + ), + "key:true".into(), + "value:true".into(), + ]); + assert_eq!( + observe(&output, &style_invocation(shape, binding, h), single_css), + (expected_trace, vec![]), + "{shape:?}/{binding:?}/h={h}\n{}", + output.code + ); + if binding.shadow() { + assert_eq!(output.styles.len(), 0); + } else { + if shape.computed_key() { + let (_, declarations) = observe( + &output, + &held_projection(shape, binding, h, true), + single_css, + ); + assert_eq!(declarations.len(), 1); + assert_eq!(declarations[0].parse::().unwrap(), 0.5); + } + if !matches!(shape, StyleShape::ComputedKey) { + let value = if matches!(shape, StyleShape::ComputedBoth) { + format!( + "(() => {{ const result = render({h}, void 0); return {{props: {{className: Object.values(result.props.style)[0]}}}}; }})()" + ) + } else { + held_projection(shape, binding, h, false) + }; + assert_eq!(observe(&output, &value, single_css).1, ["hidden"]); + } + if matches!(shape, StyleShape::Combined) { + let invocation = format!( + "(() => {{ const result = render({h}, void 0); return {{props: {{className: result.props.style['--explicit-key']}}}}; }})()" + ); + let (_, declarations) = observe(&output, &invocation, single_css); + assert_eq!(declarations.len(), 1); + assert_eq!(declarations[0].parse::().unwrap(), 0.5); + } + } + } +} diff --git a/libs/extractor/src/ci_factory_alias_support_tests.rs b/libs/extractor/src/ci_factory_alias_support_tests.rs new file mode 100644 index 000000000..adfabc316 --- /dev/null +++ b/libs/extractor/src/ci_factory_alias_support_tests.rs @@ -0,0 +1,78 @@ +use super::*; + +#[derive(Clone, Copy, Debug)] +enum Binding { + Alias, + Namespace, + ShadowAlias, + ShadowNamespace, +} + +impl Binding { + fn imports(self) -> &'static str { + match self { + Self::Alias | Self::ShadowAlias => "import { Box as t, css as i } from '@devup-ui/react';", + Self::Namespace | Self::ShadowNamespace => "import { Box as t } from '@devup-ui/react'; import * as ui from '@devup-ui/react';", + } + } + + fn parameter(self) -> &'static str { + match self { + Self::Alias | Self::Namespace => "unused", + Self::ShadowAlias => "i", + Self::ShadowNamespace => "ui", + } + } + + fn call(self) -> &'static str { + match self { + Self::Alias | Self::ShadowAlias => "i({ overflow: 'hidden' })", + Self::Namespace | Self::ShadowNamespace => "ui.css({ overflow: 'hidden' })", + } + } + + fn argument(self, truthy: bool) -> String { + let helper = format!("rules => {{ trace.push('own:' + rules.overflow); return {}; }}", if truthy { "'local'" } else { "''" }); + match self { + Self::Alias | Self::Namespace => "void 0".into(), + Self::ShadowAlias => helper, + Self::ShadowNamespace => format!("{{ css: {helper} }}"), + } + } + + fn shadow(self) -> bool { + matches!(self, Self::ShadowAlias | Self::ShadowNamespace) + } +} + +fn extract_fixture(source: &str, single_css: bool, active: bool) -> ExtractOutput { + extract( + "/src/W70h-native.js", + source, + ExtractOption { + single_css, + import_aliases: if active { + HashMap::from([("@emotion/react".into(), ImportAlias::NamedToNamed)]) + } else { + HashMap::new() + }, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{source}\n{error}")) +} + +fn observe(output: &ExtractOutput, invocation: &str, single_css: bool) -> (Vec, Vec) { + let observed = render_observation(output, invocation, (!single_css).then_some("/src/W70h-native.js")); + let (trace, mut declarations): (Vec, Vec) = serde_json::from_str(&observed).unwrap(); + // These fixtures have distinct CSS properties, never competing declarations. + declarations.sort(); + (trace, declarations) +} + +fn assert_own_styles(output: &ExtractOutput, binding: Binding) { + let own = output.styles.iter().filter(|style| matches!(style, ExtractStyleValue::Static(style) if style.property() == "overflow" && style.value() == "hidden")).count(); + assert_eq!(own > 0, !binding.shadow(), "{:?}", output.styles); +} + +const CLSX: &str = "(...parts) => { trace.push('clsx'); return parts.map(part => typeof part === 'string' ? part : Object.keys(part).filter(key => part[key]).join(' ')).join(' '); }"; diff --git a/libs/extractor/src/ci_factory_alias_tests.rs b/libs/extractor/src/ci_factory_alias_tests.rs new file mode 100644 index 000000000..e899d61ae --- /dev/null +++ b/libs/extractor/src/ci_factory_alias_tests.rs @@ -0,0 +1,58 @@ +use super::*; + +#[rstest] +#[case::alias(("import { Box as t, css as i } from '@devup-ui/react';", "", "i"))] +#[case::namespace(("import { Box as t } from '@devup-ui/react'; import * as ui from '@devup-ui/react';", "", "ui.css"))] +#[case::shadowed(("import { Box as t, css as i } from '@devup-ui/react';", "i", "i"))] +#[serial] +fn conditional_factory_class_links_styles_without_compiling_shadowed_helpers( + #[case] sample: (&str, &str, &str), + #[values(false, true)] single_css: bool, +) { + // Given: the factory/conditional shape emitted for the built Button. + let _state = crate::test_state::TestStateGuard::new(); + let (imports, parameter, callee) = sample; + let source = format!( + "{imports} import {{ jsx as o }} from 'react/jsx-runtime'; + function render(h, {parameter}) {{ return o(t, {{ className: h ? {callee}({{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}) : void 0 }}); }}" + ); + // When + let output = extract( + "/src/built-button.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{source}\n{error}")); + // Then: execute generated code and match selected classes to emitted styles. + let filename = (!single_css).then_some("/src/built-button.js"); + if parameter == "i" { + assert_eq!(output.styles.len(), 0); + assert_eq!( + render_observation( + &output, + "render(true, rules => { trace.push(rules.overflow); return 'external'; })", + filename + ), + r#"[["hidden"],[]]"# + ); + } else { + assert_eq!(output.styles.len(), 3); + let observed = render_observation(&output, "render(true)", filename); + let (trace, mut declarations): (Vec, Vec) = + serde_json::from_str(&observed).unwrap(); + declarations.sort(); + assert_eq!(trace.len(), 0); + assert_eq!(declarations, ["ellipsis", "hidden", "nowrap"]); + assert_eq!( + render_observation(&output, "render(false)", filename), + "[[],[]]" + ); + } +} + +mod native { + include!("ci_factory_alias_native_tests.rs"); +} diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index 35b2b2ab8..adc219c45 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -301,9 +301,14 @@ pub(crate) fn extract_with_policy<'a, const LOCATE: bool>( .map(|v| v as u8); } else if name == "styleVars" { style_vars = - Some(prop.value.clone_in(ast_builder.allocator())); + Some(prop.value.clone_in_with_semantic_ids( + ast_builder.allocator(), + )); } else if name == "props" { - props = Some(prop.value.clone_in(ast_builder.allocator())); + props = + Some(prop.value.clone_in_with_semantic_ids( + ast_builder.allocator(), + )); } else { let ExtractResult { styles, tag: _tag, .. diff --git a/libs/extractor/src/imported_constants_px_tests.rs b/libs/extractor/src/imported_constants_px_tests.rs index 052927ffa..6fc49d25f 100644 --- a/libs/extractor/src/imported_constants_px_tests.rs +++ b/libs/extractor/src/imported_constants_px_tests.rs @@ -1,6 +1,78 @@ use super::*; use rstest::rstest; +#[test] +fn own_css_record_keeps_fallback_order_when_authored_px_mark_is_inlined() { + // Given: Emotion-style fallback arrays, not responsive breakpoints. + let source = "import { css } from '@devup-ui/react'; +const rules = { padding: [2, 4], opacity: [0, 0.5] }; +export const card = css`${rules}`;"; + let allocator = Allocator::default(); + let mut program = crate::parsing::parse(&allocator, "/src/fallback.ts", source) + .unwrap_or_else(|error| panic!("invalid fallback fixture: {error}")); + let mut marks = crate::import_alias_visit::PxMarks::default(); + let start = u32::try_from( + source + .rfind("rules}") + .unwrap_or_else(|| panic!("missing interpolation")), + ) + .unwrap_or_else(|error| panic!("fixture offset exceeds u32: {error}")); + marks.insert((start, start + 5)); + // When + let inlined = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "/src/fallback.ts", + &ExtractOption::default(), + None, + CssProp::Off, + &marks, + ); + // Then: preserve ordered fallbacks, adding px only to length values. + assert_eq!(inlined.css_failures.len(), 0); + let Some(Statement::ExportDeclaration(export)) = program.body.last() else { + panic!("expected card") + }; + let oxc_ast::ast::Declaration::VariableDeclaration(declaration) = &export.declaration else { + panic!("expected declaration") + }; + let Some(Expression::TaggedTemplateExpression(tagged)) = + declaration.declarations[0].init.as_ref() + else { + panic!("expected tag") + }; + let Expression::ObjectExpression(object) = &tagged.quasi.expressions[0] else { + panic!("expected materialized rules") + }; + let values: Vec<_> = object + .properties + .iter() + .map(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + panic!("expected property") + }; + let Expression::StringLiteral(value) = &property.value else { + panic!("expected ordered fallback") + }; + ( + property + .key + .static_name() + .unwrap_or_else(|| panic!("expected static key")) + .to_string(), + value.value.to_string(), + ) + }) + .collect(); + assert_eq!( + values, + [ + ("padding".to_string(), "2px;padding:4px".to_string()), + ("opacity".to_string(), "0;opacity:0.5".to_string()) + ] + ); +} + #[rstest] fn own_css_record_honors_authored_px_marks_when_inlined(#[values(false, true)] marked: bool) { // Given: only the authored interpolation is marked, not its declaration. diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 07757dcfe..2a068b314 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -1041,6 +1041,9 @@ mod tests { include!("const_object_css_tag_policy_tests.rs"); } include!("stylex_exact_key_tests.rs"); + mod ci_factory_alias { + include!("ci_factory_alias_tests.rs"); + } use super::*; use crate::extract_style::style_property::StyleProperty; diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 76a2f62e7..494d8f62a 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -71,7 +71,7 @@ fn resolve_class_name_expression<'a>( get_class_name_expression(ast_builder, class_name_prop, styles, style_order, filename); let alt_class_name_prop = class_name_prop .as_ref() - .map(|c| c.clone_in(ast_builder.allocator())); + .map(|c| c.clone_in_with_semantic_ids(ast_builder.allocator())); let (alt_expr, alt_tailwind) = get_class_name_expression( ast_builder, &alt_class_name_prop, @@ -117,7 +117,10 @@ pub fn modify_prop_object<'a>( Some("className" | "style") ) => { - let value = Written::Prop(attr.value.clone_in(ast_builder.allocator())); + let value = Written::Prop( + attr.value + .clone_in_with_semantic_ids(ast_builder.allocator()), + ); if get_str_by_property_key(&attr.key).as_deref() == Some("className") { class_names.push(value); } else { @@ -176,7 +179,7 @@ pub fn modify_prop_object<'a>( if let Some(ex) = props_prop { props.push(ObjectPropertyKind::new_spread_property( SPAN, - ex.clone_in(ast_builder.allocator()), + ex.clone_in_with_semantic_ids(ast_builder.allocator()), ast_builder, )); } @@ -446,7 +449,10 @@ pub(crate) fn add_class_and_style_to_object<'a>( ObjectPropertyKind::ObjectProperty(attr) if get_str_by_property_key(&attr.key).is_some_and(|key| adds(&key)) => { - let value = Written::Prop(attr.value.clone_in(ast_builder.allocator())); + let value = Written::Prop( + attr.value + .clone_in_with_semantic_ids(ast_builder.allocator()), + ); if get_str_by_property_key(&attr.key).as_deref() == Some("className") { class_names.push(value); } else { @@ -713,7 +719,9 @@ impl TailwindClassName<'_> { } Some(Expression::new_conditional_expression( conditional.span, - conditional.test.clone_in(ast_builder.allocator()), + conditional + .test + .clone_in_with_semantic_ids(ast_builder.allocator()), compiled_or(ast_builder, consequent, &conditional.consequent), compiled_or(ast_builder, alternate, &conditional.alternate), ast_builder, @@ -743,7 +751,7 @@ impl TailwindClassName<'_> { } Expression::CallExpression(call) => self.compile_call(ast_builder, call), Expression::ArrayExpression(array) => { - let mut compiled = array.clone_in(ast_builder.allocator()); + let mut compiled = array.clone_in_with_semantic_ids(ast_builder.allocator()); let mut changed = false; for element in &mut compiled.elements { if let Some(expression) = element @@ -757,7 +765,7 @@ impl TailwindClassName<'_> { changed.then_some(Expression::ArrayExpression(compiled)) } Expression::ObjectExpression(object) => { - let mut compiled = object.clone_in(ast_builder.allocator()); + let mut compiled = object.clone_in_with_semantic_ids(ast_builder.allocator()); let mut changed = false; for property in &mut compiled.properties { let ObjectPropertyKind::ObjectProperty(property) = property else { @@ -794,7 +802,7 @@ impl TailwindClassName<'_> { ast_builder: &AstBuilder<'a>, call: &CallExpression<'a>, ) -> Option> { - let mut compiled = call.clone_in(ast_builder.allocator()); + let mut compiled = call.clone_in_with_semantic_ids(ast_builder.allocator()); let mut changed = false; match &call.callee { Expression::Identifier(callee) if matches!(callee.name.as_str(), "cn" | "twMerge") => { @@ -926,7 +934,7 @@ fn compiled_or<'a>( compiled: Option>, original: &Expression<'a>, ) -> Expression<'a> { - compiled.unwrap_or_else(|| original.clone_in(ast_builder.allocator())) + compiled.unwrap_or_else(|| original.clone_in_with_semantic_ids(ast_builder.allocator())) } /// The raw text of a template literal part whose value is `cooked` @@ -1079,7 +1087,7 @@ pub fn get_style_expression<'a>( { style_expressions.push(style_vars); } - if let Some(style_prop) = style_prop.clone_in(ast_builder.allocator()) { + if let Some(style_prop) = style_prop.clone_in_with_semantic_ids(ast_builder.allocator()) { style_expressions.push(style_prop); } merge_object_expressions(ast_builder, &style_expressions) @@ -1098,7 +1106,7 @@ fn merge_string_expressions<'a>( Expression::StringLiteral(_) | Expression::TemplateLiteral(_) ) { - return Some(expression.clone_in(ast_builder.allocator())); + return Some(expression.clone_in_with_semantic_ids(ast_builder.allocator())); } let mut list = ClassList { @@ -1141,14 +1149,15 @@ fn merge_string_expressions<'a>( list.text().push(' '); } list.interpolate( - template.expressions[index].clone_in(ast_builder.allocator()), + template.expressions[index] + .clone_in_with_semantic_ids(ast_builder.allocator()), ); } } } _ => { list.separate(); - list.interpolate(expression.clone_in(ast_builder.allocator())); + list.interpolate(expression.clone_in_with_semantic_ids(ast_builder.allocator())); } } } @@ -1222,7 +1231,7 @@ pub fn convert_class_name<'a>( | Expression::TemplateLiteral(_) | Expression::NumericLiteral(_) ) { - return class_name.clone_in(ast_builder.allocator()); + return class_name.clone_in_with_semantic_ids(ast_builder.allocator()); } // wrap ( and ?? '' @@ -1230,7 +1239,7 @@ pub fn convert_class_name<'a>( SPAN, Expression::new_parenthesized_expression( SPAN, - class_name.clone_in(ast_builder.allocator()), + class_name.clone_in_with_semantic_ids(ast_builder.allocator()), ast_builder, ), LogicalOperator::Or, @@ -1243,7 +1252,7 @@ pub fn convert_style_vars<'a>( ast_builder: &AstBuilder<'a>, style_vars: &Expression<'a>, ) -> Expression<'a> { - let mut style_vars = style_vars.clone_in(ast_builder.allocator()); + let mut style_vars = style_vars.clone_in_with_semantic_ids(ast_builder.allocator()); if let Expression::ObjectExpression(obj) = &mut style_vars { for idx in (0..obj.properties.len()).rev() { let mut prop = obj.properties.remove(idx); @@ -1283,12 +1292,14 @@ pub fn convert_style_vars<'a>( ast_builder, ), oxc_allocator::Vec::from_array_in( - [p.key.to_expression().clone_in(ast_builder.allocator())], + [p.key + .to_expression() + .clone_in_with_semantic_ids(ast_builder.allocator())], ast_builder, ), ast_builder, )), - p.value.clone_in(ast_builder.allocator()), + p.value.clone_in_with_semantic_ids(ast_builder.allocator()), false, false, true, diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 570a48c39..31f31151f 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -664,7 +664,7 @@ pub(super) fn merge_object_expressions<'a>( return None; } if expressions.len() == 1 { - return Some(expressions[0].clone_in(ast_builder.allocator())); + return Some(expressions[0].clone_in_with_semantic_ids(ast_builder.allocator())); } // Built before the call for the same reason as `wrap_direct_call`: an inline // block argument blocks rustfmt from collapsing the call, and coverage cannot @@ -673,7 +673,7 @@ pub(super) fn merge_object_expressions<'a>( for ex in expressions { props.push(ObjectPropertyKind::new_spread_property( SPAN, - ex.clone_in(ast_builder.allocator()), + ex.clone_in_with_semantic_ids(ast_builder.allocator()), ast_builder, )); } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index fc9d1de31..999e6f0a6 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -4318,7 +4318,7 @@ impl<'a> VisitMut<'a> for DevupVisitor<'a> { it.arguments[0] = Argument::from(tag); if !read_once.is_empty() { let call = Expression::CallExpression(oxc_allocator::Box::new_in( - it.clone_in(self.ast.allocator()), + it.clone_in_with_semantic_ids(self.ast.allocator()), &self.ast, )); if let Expression::CallExpression(reading_once) = From 35019a3ecb8834b68bffd95469d25a6e653f6d4a Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 21 Oct 2026 14:56:56 +0900 Subject: [PATCH 3/3] test(extractor): cover namespace object css tags Refs #688 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/const_object_css_tag_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/extractor/src/const_object_css_tag_tests.rs b/libs/extractor/src/const_object_css_tag_tests.rs index 5d0a7aa3c..beac120bb 100644 --- a/libs/extractor/src/const_object_css_tag_tests.rs +++ b/libs/extractor/src/const_object_css_tag_tests.rs @@ -12,6 +12,8 @@ const MODULES: &[(&str, &str)] = &[( #[case::direct(("", "css`${{ selectors: { '&:hover': { color: 'blue' } } }}`", &[HOVER][..]))] #[case::alias(("const rules = { selectors: { '&:hover': { color: 'blue' } } }; const alias = rules;", "css`${alias}`", &[HOVER][..]))] #[case::imported(("import { rules as alias } from './rules';", "css`${alias}`", &[HOVER][..]))] +#[case::namespace_local_record(("import * as Devup from '@devup-ui/react'; const local = { selectors: { '&:hover': { color: 'teal' } } };", "Devup.css`${local}`", &["color-0-teal-_a__c_hover-255"][..]))] +#[case::namespace_imported_record(("import * as Devup from '@devup-ui/react'; import { rules as alias } from './rules';", "Devup.css`${alias}`", &[HOVER][..]))] #[case::normal_call((RULES, "css(rules)", &[HOVER][..]))] #[case::compiled_class(("const base = css({ color: 'red' });", "css`${base};`", &["color-0-red--255"][..]))] #[case::mixed_scalar(("const rules = { selectors: { '&:hover': { color: 'blue' } } }; const tone = 'red';", "css`${rules}; color: ${tone}; margin: 0;`", &[HOVER, "color-0-red--255", "margin-0-0--255"][..]))] @@ -68,7 +70,7 @@ fn card_selects_linked_atoms_when_tag_statements_are_composed( matches!(style.extract((!single_css).then_some(filename)), Some(StyleProperty::ClassName(name)) if name == class) }), "selected class {class} has no emitted style: {output:?}"); } - if binding.starts_with("import ") { + if binding.contains("from './rules';") { assert!( output .dependencies