diff --git a/.changepacks/changepack_log_constant_read_work.json b/.changepacks/changepack_log_constant_read_work.json new file mode 100644 index 000000000..24c2563f4 --- /dev/null +++ b/.changepacks/changepack_log_constant_read_work.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Avoid cloning unreferenced constant initializers and defer initialization analysis until constant reads need it. Extraction output and build errors are unchanged.", + "date": "2026-10-24T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_extraction_coverage.json b/.changepacks/changepack_log_extraction_coverage.json new file mode 100644 index 000000000..3f18b0e84 --- /dev/null +++ b/.changepacks/changepack_log_extraction_coverage.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Add regression coverage for extraction safety, property pruning and source-order behavior; remove unreachable post-flatten spread handling and consolidate unknown property-shape rejection. Extraction output and build errors are unchanged.", + "date": "2026-10-24T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_js_semantics.json b/.changepacks/changepack_log_js_semantics.json new file mode 100644 index 000000000..df0c3cacd --- /dev/null +++ b/.changepacks/changepack_log_js_semantics.json @@ -0,0 +1,9 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch", + "packages/components/package.json": "Patch", + "packages/reset-css/package.json": "Patch" + }, + "note": "Keep unknown element-prop spreads wholly as native pass-through data: their keys never select, replace or remove separately authored styles, regardless of order. Preserve fully known ordinary spread composition, canonical aliases, source-ordered once-only values and native className/style merging without spread-key styling variables, fallbacks or conversion code. Preserve exact imported enum and Math values including negative zero, lexical constants, binding-aware CommonJS require, React createElement calls, type-only imports, and compiled reset CSS tests. Retain located errors for intrinsically unreadable explicit styles, runtime styleOrder, unmovable lazy logical suspension, whole Devup UI namespaces escaping to runtime, and immediate lexical temporal-dead-zone reads. Native Emotion css spread restrictions and separate styled css overlap restrictions remain unchanged.", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_jsx_call_spread.json b/.changepacks/changepack_log_jsx_call_spread.json new file mode 100644 index 000000000..f52c04b82 --- /dev/null +++ b/.changepacks/changepack_log_jsx_call_spread.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Attach extracted classes and authored CSS variables to jsx, jsxs and jsxDEV props wrapped in parentheses, as const or satisfies. Preserve source evaluation order, responsive and conditional styleOrder output, native className/style merging and whole unknown-spread pass-through. Syntax wrappers do not introduce spread replacement diagnostics or a styling runtime.", + "date": "2026-10-12T00:00:00.000Z" +} diff --git a/libs/extractor/src/barrel.rs b/libs/extractor/src/barrel.rs index 52062045f..d7c0644c3 100644 --- a/libs/extractor/src/barrel.rs +++ b/libs/extractor/src/barrel.rs @@ -503,6 +503,7 @@ impl Generated { mod aliases; mod gate; +mod namespace_aliases; use aliases::{Reach, declarator_removal, export_edits, is_assigned_from}; fn unreadable(local: &str, code: &str) -> String { @@ -792,7 +793,17 @@ impl Rewriter<'_, '_, '_, '_> { self.replace(namespace, appended, member_span, &name, origin); } (None, AstKind::VariableDeclarator(declarator)) - if matches!(declarator.id, BindingPattern::BindingIdentifier(_)) => {} + if matches!(declarator.id, BindingPattern::BindingIdentifier(_)) + && declarator + .init + .as_ref() + .is_some_and(|init| init.span() == span) + && nodes + .parent_kind(nodes.parent_id(node)) + .as_variable_declaration() + .is_some_and(|declaration| { + declaration.kind == VariableDeclarationKind::Const + }) => {} (None, AstKind::VariableDeclarator(declarator)) if declarator .init @@ -804,7 +815,11 @@ impl Rewriter<'_, '_, '_, '_> { (None, AstKind::ComputedMemberExpression(member)) if member.object.span() == span => { self.whole_error(local, member.span.start, member.span.end); } - (None, _) => {} + (None, AstKind::ExportSpecifier(_)) + if self.edits.iter().any(|(start, end, _)| { + *start <= span.start as usize && *end >= span.end as usize + }) => {} + (None, _) => self.whole_error(local, span.start, span.end), } } @@ -1022,6 +1037,7 @@ pub(crate) fn rewrite( } } let wants_semantic = !found.namespaces.is_empty() + || found.bound.iter().any(|bound| bound.devup.1.is_none()) || !found.opaque.is_empty() || (found .bound @@ -1042,7 +1058,7 @@ pub(crate) fn rewrite( let locals: FxHashMap = found .bound .iter() - .filter(|bound| bound.devup.1.as_deref().is_some_and(compiled_export)) + .filter(|bound| bound.devup.1.as_deref().is_none_or(compiled_export)) .filter_map(|bound| { Some(( bound.binding.symbol_id.get()?, @@ -1085,6 +1101,19 @@ pub(crate) fn rewrite( module: declared.module.clone(), source: declared.source.clone(), }) + .chain( + found + .bound + .iter() + .filter(|bound| bound.devup.1.is_none()) + .map(|bound| Namespace { + local: bound.binding.name.to_string(), + symbol: bound.binding.symbol_id.get(), + after: bound.after, + module: None, + source: bound.devup.0.clone(), + }), + ) .collect(); for namespace in &spaces { rewriter.read_namespace(namespace); diff --git a/libs/extractor/src/barrel/aliases.rs b/libs/extractor/src/barrel/aliases.rs index 1771f9e1b..f9d9cf97d 100644 --- a/libs/extractor/src/barrel/aliases.rs +++ b/libs/extractor/src/barrel/aliases.rs @@ -223,12 +223,13 @@ impl Rewriter<'_, '_, '_, '_> { Some((bound.binding.symbol_id.get()?, (bound, bound.devup.clone()))) }) .collect(); - let mut spaces: FxHashMap = namespaces + let first_new = namespaces.len(); + self.namespace_aliases(namespaces); + let spaces: FxHashMap = namespaces .iter() .enumerate() .filter_map(|(index, namespace)| Some((namespace.symbol?, index))) .collect(); - let first_new = namespaces.len(); for node in nodes.iter() { let AstKind::VariableDeclarator(declarator) = node.kind() else { continue; @@ -249,18 +250,10 @@ impl Rewriter<'_, '_, '_, '_> { if declaration.kind != VariableDeclarationKind::Const { continue; } - if let Some(&index) = spaces.get(&origin) { - let origin = &namespaces[index]; - let namespace = Namespace { - local: id.name.to_string(), - symbol: Some(alias), - after: origin.after, - module: origin.module.clone(), - source: origin.source.clone(), - }; - spaces.insert(alias, namespaces.len()); - namespaces.push(namespace); - } else if let Some((origin, reach)) = named.get(&origin).cloned() { + if spaces.contains_key(&origin) { + continue; + } + if let Some((origin, reach)) = named.get(&origin).cloned() { named.insert(alias, (origin, reach.clone())); let exported = matches!( nodes.parent_kind(declaration_id), diff --git a/libs/extractor/src/barrel/namespace_aliases.rs b/libs/extractor/src/barrel/namespace_aliases.rs new file mode 100644 index 000000000..26a9e3905 --- /dev/null +++ b/libs/extractor/src/barrel/namespace_aliases.rs @@ -0,0 +1,58 @@ +//! Whole namespace aliases, followed by semantic identity regardless of declaration order. + +use oxc_ast::AstKind; +use oxc_ast::ast::{BindingPattern, Expression, VariableDeclarationKind}; +use rustc_hash::FxHashMap; + +use super::{Namespace, Rewriter, reference_symbol}; + +impl Rewriter<'_, '_, '_, '_> { + /// Collects every immutable namespace alias before checking its runtime reads. + pub(super) fn namespace_aliases(&self, namespaces: &mut Vec) { + let mut spaces: FxHashMap<_, _> = namespaces + .iter() + .enumerate() + .filter_map(|(index, namespace)| Some((namespace.symbol?, index))) + .collect(); + let nodes = self.semantic.nodes(); + loop { + let before = spaces.len(); + for node in nodes.iter() { + let AstKind::VariableDeclarator(declarator) = node.kind() else { + continue; + }; + let (BindingPattern::BindingIdentifier(id), Some(Expression::Identifier(init))) = + (&declarator.id, &declarator.init) + else { + continue; + }; + let (Some(alias), Some(origin), AstKind::VariableDeclaration(declaration)) = ( + id.symbol_id.get(), + reference_symbol(init, self.semantic), + nodes.parent_kind(node.id()), + ) else { + continue; + }; + if declaration.kind != VariableDeclarationKind::Const || spaces.contains_key(&alias) + { + continue; + } + if let Some(&index) = spaces.get(&origin) { + let origin = &namespaces[index]; + let namespace = Namespace { + local: id.name.to_string(), + symbol: Some(alias), + after: origin.after, + module: origin.module.clone(), + source: origin.source.clone(), + }; + spaces.insert(alias, namespaces.len()); + namespaces.push(namespace); + } + } + if spaces.len() == before { + break; + } + } + } +} diff --git a/libs/extractor/src/barrel/namespace_escape_tests.rs b/libs/extractor/src/barrel/namespace_escape_tests.rs new file mode 100644 index 000000000..d55d29419 --- /dev/null +++ b/libs/extractor/src/barrel/namespace_escape_tests.rs @@ -0,0 +1,133 @@ +use super::{NS, Project, namespace_code, namespace_error}; +use crate::barrel::{Barreled, rewrite}; +use serial_test::serial; + +#[test] +#[serial] +fn runtime_namespace_escapes_are_rejected_when_used_whole() { + for usage in [ + "consume(Devup)", + "Object.keys(Devup)", + "function f() { return Devup }", + "const value = { ns: Devup }", + "const value = { Devup }", + "const value = [Devup]", + "export default Devup", + "export default { Devup }", + "const value = typeof Devup", + "let D = Devup; consume(D)", + "var D = Devup; consume(D)", + "const D = Devup; spyOn(D, 'css')", + "const D = Devup; const E = D; consume(E)", + "const D = Devup; export default D", + "function f() { const D = Devup; return D }", + "const E = D; const D = Devup; consume(E)", + "export { D }; const D = Devup", + "consume((Devup))", + "consume(Devup as object)", + ] { + let source = format!("{NS}{usage}\n"); + + let error = namespace_error(&source); + + assert!(error.contains("test.tsx:2:"), "{usage}: {error}"); + assert!( + error.contains("cannot use") && error.contains("read its members by name"), + "{usage}: {error}" + ); + } +} + +#[test] +#[serial] +fn safe_namespace_reads_preserve_semantic_identity() { + let source = format!( + "{NS}const D = Devup; const E = D;\nconst {{ css: c }} = E;\nexport const a = c({{ color: 'red' }});\nexport const b = D.css({{ color: 'blue' }});\nexport type N = typeof Devup;\nexport type A = typeof D;\nexport function f(Devup) {{ return spyOn(Devup, 'css') }}\nexport function g(D) {{ return D }}\nexport {{ Devup as UI }};\n" + ); + + let code = namespace_code(&source); + + assert!(!code.contains("c({") && !code.contains("D.css("), "{code}"); + assert!(code.contains("spyOn(Devup, \"css\")"), "{code}"); + assert!(code.contains("return D"), "{code}"); + assert!( + code.contains("typeof Devup") && code.contains("typeof D"), + "{code}" + ); + assert!(code.contains("export * as UI"), "{code}"); +} + +#[test] +#[serial] +fn type_only_and_unrelated_namespaces_remain_unchanged() { + for source in [ + "import type * as Devup from '@devup-ui/react'; export type N = typeof Devup;", + "import * as Devup from 'other'; spyOn(Devup, 'css');", + "import * as Devup from '@devup-ui/react'; function f() { const Devup = {}; return Devup }", + ] { + let result = rewrite(source, "test.tsx", "@devup-ui/react", None); + + assert!(matches!(result, Barreled::Unchanged)); + } +} + +#[test] +#[serial] +fn reexported_namespaces_are_rejected_when_their_runtime_value_escapes() { + for (index, (barrel, import)) in [ + ( + "export { css } from '@devup-ui/react'", + "import * as Devup from './ui'", + ), + ( + "export * as Devup from '@devup-ui/react'", + "import { Devup } from './ui'", + ), + ( + "import * as D from '@devup-ui/react'; export default D", + "import Devup from './ui'", + ), + ( + "import * as D from '@devup-ui/react'; export const Devup = D", + "import { Devup } from './ui'", + ), + ( + "export * as Devup from './inner'", + "import { Devup } from './ui'", + ), + ] + .into_iter() + .enumerate() + { + let source = format!("{import}\nspyOn(Devup, 'css')\n"); + let project = Project::new( + &format!("escape-{index}"), + &[ + ("ui.ts", barrel), + ("inner.ts", "export { css } from '@devup-ui/react'"), + ("app.tsx", &source), + ], + ); + + let error = project.error("app.tsx"); + + assert!(error.contains("app.tsx:2:7:"), "{error}"); + assert!( + error.contains("cannot use") && error.contains("import"), + "{error}" + ); + } +} + +#[test] +#[serial] +fn default_namespace_import_is_rejected_when_passed_to_runtime() { + let source = "import Devup from '@devup-ui/react'\nspyOn(Devup, 'css')\n"; + + let error = namespace_error(source); + + assert!( + error.contains("test.tsx:2:7: `Devup` cannot use `Devup`"), + "{error}" + ); +} diff --git a/libs/extractor/src/barrel/tests.rs b/libs/extractor/src/barrel/tests.rs index d509e81d8..76fb93e80 100644 --- a/libs/extractor/src/barrel/tests.rs +++ b/libs/extractor/src/barrel/tests.rs @@ -11,6 +11,9 @@ use crate::{ has_devup_ui_with, }; +#[path = "namespace_escape_tests.rs"] +mod namespace_escape_tests; + /// A project of real files on disk, removed when dropped struct Project { root: PathBuf, @@ -838,12 +841,13 @@ fn long_code_in_an_error_is_shortened() { #[test] #[serial] -fn namespace_passed_whole_is_left_to_the_runtime() { - let code = namespace_code(&format!( - "{NS}export const a = Object.keys(Devup)\nexport const b = ((fn) => fn)(Devup)\nexport default Devup\nexport const c = typeof Devup\n" - )); - assert!(code.contains("Object.keys(Devup)"), "{code}"); - assert!(code.contains("export default Devup"), "{code}"); +fn namespace_passed_whole_is_a_located_build_error() { + let error = namespace_error(&format!("{NS}spyOn(Devup, 'css')\n")); + assert!( + error.contains("test.tsx:2:7: `Devup` cannot use `Devup` at build time"), + "{error}" + ); + assert!(error.contains("read its members by name"), "{error}"); } #[test] @@ -983,11 +987,10 @@ fn barrel_module_exports_what_it_aliases_as_re_exports() { #[serial] fn namespace_aliases_read_members() { let code = namespace_code(&format!( - "{NS}const D = Devup\nexport const a = \nexport const b = D.css({{ color: 'blue' }})\nlet L = Devup\nexport const c = L\n" + "{NS}const D = Devup\nexport const a = \nexport const b = D.css({{ color: 'blue' }})\n" )); assert!(code.contains("
{ + Number(f64), + String(&'a str), + Bool(bool), + Null, + Undefined, +} + +impl Operand<'_> { + fn literal(&self) -> Option { + match self { + Self::Number(number) if *number == 0.0 && number.is_sign_negative() => { + Some("-0".to_string()) + } + Self::Number(number) => Some(crate::utils::js_number_string(*number)), + Self::String(text) => serde_json::to_string(text).ok(), + Self::Bool(value) => Some(value.to_string()), + Self::Null => Some("null".to_string()), + Self::Undefined => Some("undefined".to_string()), + } + } +} + +/// Evaluate only a trusted exact member over already resolved primitive operands. +/// No source expressions, user functions or module code reach Boa here. +pub(crate) fn evaluate(name: &str, arguments: Option<&[Operand<'_>]>) -> Option { + if !EXACT_MATH.contains(&name) { + return None; + } + let expression = match arguments { + None => format!("Math.{name}"), + Some(arguments) => { + let arguments: Option> = arguments.iter().map(Operand::literal).collect(); + format!("Math.{name}({})", arguments?.join(",")) + } + }; + let value = Context::default() + .eval(Source::from_bytes(expression.as_bytes())) + .ok()? + .as_number()?; + value.is_finite().then_some(value) +} diff --git a/libs/extractor/src/build_time_values/scope_tests.rs b/libs/extractor/src/build_time_values/scope_tests.rs index 310d45aac..ca3b0af2c 100644 --- a/libs/extractor/src/build_time_values/scope_tests.rs +++ b/libs/extractor/src/build_time_values/scope_tests.rs @@ -1,6 +1,20 @@ use super::has_build_time_values; use crate::ExtractOption; +#[rstest::rstest] +#[case("await 'red'", false)] +#[case("'red'.toUpperCase()", true)] +fn build_time_candidate_when_evaluation_can_suspend_is_rejected( + #[case] value: &str, + #[case] expected: bool, +) { + let source = format!("import {{css}} from '@devup-ui/react'; css({{color: {value}}});"); + + let actual = has_build_time_values("await.tsx", &source, &ExtractOption::default(), None); + + assert_eq!(actual, expected); +} + #[test] fn a_local_named_like_a_style_api_computes_nothing() { let option = ExtractOption::default(); diff --git a/libs/extractor/src/build_time_values/w22_tests.rs b/libs/extractor/src/build_time_values/w22_tests.rs new file mode 100644 index 000000000..cad62b9e9 --- /dev/null +++ b/libs/extractor/src/build_time_values/w22_tests.rs @@ -0,0 +1,43 @@ +use super::*; +use rstest::rstest; + +#[rstest] +#[case( + "import {css} from '@devup-ui/react';function make(n){return n+'px';}export const a=css({w:make(2)});", + "\"2px\"" +)] +#[case( + "import {css} from '@devup-ui/react';const n=2;export const a=css({w:(n+3)+'px'});", + "\"5px\"" +)] +#[case( + "import {css} from '@devup-ui/react';const theme={color:'red'};export const a=css({color:theme.color});", + "\"red\"" +)] +fn original_build_time_evaluation_when_the_dependency_closure_is_exact_replaces_values( + #[case] source: &str, + #[case] expected: &str, +) { + let actual = evaluate( + source, + "a.tsx", + &crate::ExtractOption::default(), + None, + &crate::imported_constants::Unknown::default(), + ) + .unwrap_or_else(|| panic!("{source}")); + assert!(actual.0.contains(expected), "{}", actual.0); + assert_ne!(actual.1.len(), 0); +} + +#[test] +fn exact_math_when_resolved_numeric_operands_are_used_preserves_the_result() { + let result = exact_math::evaluate( + "imul", + Some(&[ + exact_math::Operand::Number(2.0), + exact_math::Operand::Number(3.0), + ]), + ); + assert_eq!(result, Some(6.0)); +} diff --git a/libs/extractor/src/extract_style/dynamic_style_tests.rs b/libs/extractor/src/extract_style/dynamic_style_tests.rs new file mode 100644 index 000000000..8771f6a0d --- /dev/null +++ b/libs/extractor/src/extract_style/dynamic_style_tests.rs @@ -0,0 +1,54 @@ +use super::*; + +#[test] +fn test_extract_dynamic_style() { + let style = ExtractDynamicStyle::new("color", 0, "primary", None); + assert_eq!(style.property(), "color"); + assert_eq!(style.level(), 0); + assert_eq!(style.selector(), None); + assert_eq!(style.identifier(), "primary"); + assert_eq!(style.style_order(), None); + assert!(!style.important()); +} + +#[test] +fn test_debug_dynamic_style() { + let style = ExtractDynamicStyle::new("color", 2, "value", None); + assert_eq!( + format!("{style:?}"), + "ExtractDynamicStyle { property: \"color\", level: 2, identifier: \"value\", selector: None, style_order: None }" + ); +} + +#[rstest::rstest] +#[case("color", "color", false)] +#[case("color !important fallback", "color !important fallback", false)] +#[case("`${color} !important`", "`${color}`", true)] +#[case("\"red !important\"", "\"red\"", true)] +#[case("'red !important'", "'red'", true)] +#[case("something !important", "something", true)] +fn important_suffix_when_present_is_stripped( + #[case] input: &str, + #[case] expected: &str, + #[case] important: bool, +) { + assert_eq!( + strip_important(input.to_string()), + (expected.to_string(), important) + ); +} + +#[test] +fn test_dynamic_style_with_important() { + let style = ExtractDynamicStyle::new("background", 0, "`${color} !important`", None); + assert_eq!(style.property(), "background"); + assert_eq!(style.identifier(), "`${color}`"); + assert!(style.important()); +} + +#[test] +fn runtime_template_when_css_ends_in_a_semicolon_keeps_valid_javascript() { + let style = ExtractDynamicStyle::new("color", 0, " `${color};`; ", None); + assert_eq!(style.identifier(), "`${color}`"); + assert!(!style.important()); +} diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index c0aefa18c..6ec784469 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -169,10 +169,12 @@ impl ExtractStyleProperty for ExtractDynamicStyle { }); let variable_name = sheet_to_variable_name(self.property.as_str(), self.level, selector.as_deref()); - let declaration = format!( - "var({variable_name}){}", - if self.important { " !important" } else { "" } - ); + let declaration = { + format!( + "var({variable_name}){}", + if self.important { " !important" } else { "" } + ) + }; StyleProperty::Variable { class_name: ordinary_class.unwrap_or_else(|| { sheet_to_classname( @@ -191,79 +193,5 @@ impl ExtractStyleProperty for ExtractDynamicStyle { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_dynamic_style() { - let style = ExtractDynamicStyle::new("color", 0, "primary", None); - assert_eq!(style.property(), "color"); - assert_eq!(style.level(), 0); - assert_eq!(style.selector(), None); - assert_eq!(style.identifier(), "primary"); - assert_eq!(style.style_order(), None); - assert!(!style.important()); - } - - #[test] - fn test_strip_important_plain() { - let (id, important) = strip_important("color".to_string()); - assert_eq!(id, "color"); - assert!(!important); - } - - #[test] - fn test_debug_dynamic_style() { - let style = ExtractDynamicStyle::new("color", 2, "value", None); - - assert_eq!( - format!("{style:?}"), - "ExtractDynamicStyle { property: \"color\", level: 2, identifier: \"value\", selector: None, style_order: None }" - ); - } - - #[test] - fn test_strip_important_when_not_a_suffix() { - let (id, important) = strip_important("color !important fallback".to_string()); - - assert_eq!(id, "color !important fallback"); - assert!(!important); - } - - #[test] - fn test_strip_important_template_literal() { - // Template literal: `${color} !important` - let (id, important) = strip_important("`${color} !important`".to_string()); - assert_eq!(id, "`${color}`"); - assert!(important); - } - - #[test] - fn test_strip_important_double_quote() { - let (id, important) = strip_important("\"red !important\"".to_string()); - assert_eq!(id, "\"red\""); - assert!(important); - } - - #[test] - fn test_strip_important_single_quote() { - let (id, important) = strip_important("'red !important'".to_string()); - assert_eq!(id, "'red'"); - assert!(important); - } - - #[test] - fn test_strip_important_bare() { - let (id, important) = strip_important("something !important".to_string()); - assert_eq!(id, "something"); - assert!(important); - } - - #[test] - fn test_dynamic_style_with_important() { - let style = ExtractDynamicStyle::new("background", 0, "`${color} !important`", None); - assert_eq!(style.property(), "background"); - assert_eq!(style.identifier(), "`${color}`"); - assert!(style.important()); - } -} +#[path = "dynamic_style_tests.rs"] +mod tests; diff --git a/libs/extractor/src/extractor/extract_style_from_expression.rs b/libs/extractor/src/extractor/extract_style_from_expression.rs index fe1af3863..1f5606ee5 100644 --- a/libs/extractor/src/extractor/extract_style_from_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_expression.rs @@ -39,6 +39,9 @@ use std::collections::BTreeMap; const IGNORED_IDENTIFIERS: [&str; 3] = ["undefined", "NaN", "Infinity"]; +#[cfg(test)] +mod direct_tests; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LiteralHandling { ExpandResponsiveThemeToken, diff --git a/libs/extractor/src/extractor/extract_style_from_expression/direct_tests.rs b/libs/extractor/src/extractor/extract_style_from_expression/direct_tests.rs new file mode 100644 index 000000000..d3c0aea1c --- /dev/null +++ b/libs/extractor/src/extractor/extract_style_from_expression/direct_tests.rs @@ -0,0 +1,170 @@ +use super::*; +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[test] +fn root_styles_when_dom_props_are_present_extract_only_styles_and_keep_dom_values() { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut expression = Parser::new( + &allocator, + "({color:'red', id:readId(), 'data-label':'kept'})", + SourceType::tsx(), + ) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + + let result = extract_style_from_expression( + &ast, + None, + &mut expression, + 0, + &None, + LiteralHandling::ExpandResponsiveThemeToken, + ); + + let values: Vec<_> = result + .styles + .iter() + .flat_map(ExtractStyleProp::extract) + .collect(); + assert!( + matches!(values.as_slice(), [ExtractStyleValue::Static(value)] + if value.property() == "color" && value.value() == "red") + ); + let remaining = expression_to_code(&expression); + assert_eq!( + evaluate( + &format!("JSON.stringify({})", remaining.trim_end_matches(';')), + "let calls=0; const readId=()=>{if(++calls!==1)throw Error('repeated'); return 'identity';};" + ), + r#"{"id":"identity","data-label":"kept"}"# + ); +} + +fn styles(source: &str, name: Option<&str>) -> (Vec, Vec) { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let mut parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{source}"); + let Statement::ExpressionStatement(statement) = &mut parsed.program.body[0] else { + panic!("expression required") + }; + let result = extract_style_from_expression( + &ast, + name, + &mut statement.expression, + 0, + &name.is_none().then(|| StyleSelector::from("hover")), + LiteralHandling::ExpandResponsiveThemeToken, + ); + let errors = result + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleProp::Unreadable { code, .. } => Some(code.clone()), + _ => None, + }) + .collect(); + ( + result + .styles + .into_iter() + .flat_map(ExtractStyleProp::into_extract) + .collect(), + errors, + ) +} + +#[rstest] +#[case("['red','blue'][-1]", &[])] +#[case("['red','blue'][1]", &["blue"])] +#[case("['red','blue'][0.5]", &[])] +#[case("['red',,'blue'][1]", &[])] +#[case("({a:'red',a:'blue'})['a']", &["blue"])] +#[case("({a:'red'})['missing']", &[])] +#[case("({a:'red',...rest,a:'green'})['a']", &["green"])] +#[case("null ?? 'red'", &["red"])] +fn literal_selection_when_the_selected_entry_is_known( + #[case] source: &str, + #[case] expected: &[&str], +) { + let (actual, errors) = styles(source, Some("color")); + let values: Vec<_> = actual + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some(style.value()), + _ => None, + }) + .collect(); + assert_eq!(values, expected, "{source}"); + assert_eq!(errors, Vec::::new()); +} + +#[rstest] +#[case("['red',...rest][1]", "const rest = ['blue'];")] +#[case("({a:'red',...rest})['a']", "const rest = {a:'blue'};")] +fn literal_selection_when_a_spread_can_replace_the_entry_is_dynamic( + #[case] source: &str, + #[case] setup: &str, +) { + let (actual, errors) = styles(source, Some("color")); + let [ExtractStyleValue::Dynamic(style)] = actual.as_slice() else { + panic!("dynamic style required: {actual:?}") + }; + assert_eq!(evaluate(style.identifier(), setup), "blue"); + assert_eq!(errors, Vec::::new()); +} + +#[test] +fn array_selection_when_the_key_is_runtime_keeps_known_prefix_and_spread() { + let (actual, errors) = styles("['red',...rest][key]", Some("color")); + assert_eq!(actual.len(), 2); + assert!(actual.iter().any(|style| matches!(style, + ExtractStyleValue::Static(style) if style.value() == "red"))); + assert!(actual.iter().any(|style| matches!(style, + ExtractStyleValue::Dynamic(style) if style.identifier().contains("...rest") && style.identifier().contains("key")))); + assert_eq!(errors, Vec::::new()); +} + +#[rstest] +#[case("`${runtime}`")] +#[case("source[key]")] +fn selector_selection_when_the_whole_shape_is_opaque_is_unreadable(#[case] source: &str) { + let (actual, errors) = styles(source, None); + assert_eq!(actual, vec![]); + assert_eq!(errors.len(), 1); + assert!(errors[0].contains(source)); +} + +#[test] +fn sequence_when_written_into_a_dynamic_style_keeps_its_parentheses() { + let (actual, errors) = styles("(first(), second())", Some("color")); + let [ExtractStyleValue::Dynamic(style)] = actual.as_slice() else { + panic!("dynamic style required: {actual:?}") + }; + assert_eq!( + evaluate( + style.identifier(), + "let trace=''; const first=()=>{trace+='1';}; const second=()=>{trace+='2';return trace;};" + ), + "12" + ); + assert_eq!(errors, Vec::::new()); +} + +fn evaluate(expression: &str, setup: &str) -> String { + let script = + format!("(()=>{{{setup} const style={{value:{expression}}}; return style.value;}})()"); + let mut context = boa_engine::Context::default(); + let value = context + .eval(boa_engine::Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}: {script}")); + value + .to_string(&mut context) + .unwrap_or_else(|error| panic!("{error}")) + .to_std_string_escaped() +} diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 853908060..8e63ade7a 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -65,6 +65,7 @@ struct Base<'a> { /// renders in its place: what it renders, its classes and styles, and its /// attrs, as the extension applies them before its own pub struct StyledDefinition<'a> { + pub(crate) takes_styles: bool, name: String, target: Option>, bound: Option>, @@ -168,6 +169,7 @@ impl<'a> StyledDefinition<'a> { } let allocator = builder.allocator(); let mut inherited = Self { + takes_styles: self.takes_styles, name: self.name.clone(), target: self.target.as_ref().map(|value| value.clone_in(allocator)), bound: self.bound.as_ref().map(|value| value.clone_in(allocator)), @@ -238,13 +240,18 @@ impl<'a> StyledDefinition<'a> { } impl StyledDefinition<'_> { + /// Whether the styles and attrs of this component read the prop `name` + #[must_use] + pub fn reads_prop(&self, name: &str) -> bool { + self.reads.whole || self.reads.names.iter().any(|read| read == name) + } + /// Whether an element using this component keeps passing the prop `name`: /// what its styles read, what it passes on, and what React or the /// component itself takes #[must_use] pub fn takes(&self, name: &str) -> bool { - self.reads.whole - || self.reads.names.iter().any(|read| read == name) + self.reads_prop(name) || matches!( name, "className" | "style" | "as" | "forwardedAs" | "key" | "ref" | "children" @@ -314,6 +321,7 @@ impl<'a> StyledDefinition<'a> { #[must_use] pub fn clone_in<'b>(&self, allocator: &'b oxc_allocator::Allocator) -> StyledDefinition<'b> { StyledDefinition { + takes_styles: self.takes_styles, name: self.name.clone(), target: self .target @@ -607,6 +615,7 @@ impl<'a> Base<'a> { let allocator = ast_builder.allocator(); let (reads, forward) = props; StyledDefinition { + takes_styles: false, name: self.name.clone(), target: self .target diff --git a/libs/extractor/src/extractor/styled_record.rs b/libs/extractor/src/extractor/styled_record.rs index c13fabcc8..b87be7c96 100644 --- a/libs/extractor/src/extractor/styled_record.rs +++ b/libs/extractor/src/extractor/styled_record.rs @@ -70,7 +70,12 @@ impl<'a> Preparation<'_, 'a> { key.as_str(), "styleOrder" | "styleVars" | "props" | "params" | "as" ) { - property.value = self.value(&property.value, guard)?; + let rules = key.starts_with(['_', '@']) + || key == "selectors" + || super::extract_style_from_expression::is_nested_selector_key( + &key, + ); + property.value = self.value(&property.value, guard, rules)?; } property.shorthand = false; let entry = Entry { diff --git a/libs/extractor/src/extractor/styled_record_value.rs b/libs/extractor/src/extractor/styled_record_value.rs index c7bcb4858..3374bb5b2 100644 --- a/libs/extractor/src/extractor/styled_record_value.rs +++ b/libs/extractor/src/extractor/styled_record_value.rs @@ -9,6 +9,10 @@ use oxc_syntax::operator::{BinaryOperator, LogicalOperator, UnaryOperator}; #[path = "styled_record_selection.rs"] mod selection; +#[cfg(test)] +#[path = "styled_record_value_tests.rs"] +mod tests; + fn lookup_object<'a, 'b>(source: &'b Expression<'a>) -> &'b Expression<'a> { match source { Expression::TSAsExpression(value) => lookup_object(&value.expression), @@ -50,7 +54,7 @@ fn template_free_lookup_index(source: &Expression<'_>) -> bool { } /// Only literal data can be replaced by the extractor's finite class lookup. -fn static_lookup_value(source: &Expression<'_>) -> bool { +fn static_lookup_value(source: &Expression<'_>, rules: bool) -> bool { let source = unwrap_syntax_only(source); if pure_lookup_literal(source) && get_string_by_literal_expression(source).is_some() { return true; @@ -62,11 +66,11 @@ fn static_lookup_value(source: &Expression<'_>) -> bool { && selection::property_key(&property.key).is_some() && (property.computed || selection::property_key(&property.key).as_deref() != Some("__proto__")) - && static_lookup_value(&property.value)) + && if rules { static_lookup_value(&property.value, true) } else { pure_lookup_literal(&property.value) }) }), Expression::ArrayExpression(array) => array.elements.iter().all(|element| match element { ArrayExpressionElement::Elision(_) => true, - _ => element.as_expression().is_some_and(static_lookup_value), + _ => element.as_expression().is_some_and(|value| if rules { static_lookup_value(value, true) } else { pure_lookup_literal(value) }), }), Expression::NullLiteral(_) => true, _ => false, @@ -78,6 +82,7 @@ impl<'a> Preparation<'_, 'a> { &mut self, source: &Expression<'a>, guard: &Expression<'a>, + rules: bool, ) -> Option> { let source = unwrap_syntax_only(source); let builder = self.reads.builder; @@ -89,7 +94,7 @@ impl<'a> Preparation<'_, 'a> { if matches!( lookup_object(&member.object), Expression::ObjectExpression(_) | Expression::ArrayExpression(_) - ) && static_lookup_value(&member.object) + ) && static_lookup_value(&member.object, rules) && template_free_lookup_index(&member.expression) => { let mut member = member.clone_in(builder.allocator()); @@ -119,8 +124,8 @@ impl<'a> Preparation<'_, 'a> { ), &self.reads.boolean(false), ); - let yes = self.value(&choice.consequent, &yes_guard)?; - let no = self.value(&choice.alternate, &no_guard)?; + let yes = self.value(&choice.consequent, &yes_guard, rules)?; + let no = self.value(&choice.alternate, &no_guard, rules)?; Some(self.reads.choose(&test, &yes, &no)) } Expression::LogicalExpression(logical) => { @@ -143,7 +148,7 @@ impl<'a> Preparation<'_, 'a> { let right_guard = self.reads .choose(guard, &takes_right, &self.reads.boolean(false)); - let right = self.value(&logical.right, &right_guard)?; + let right = self.value(&logical.right, &right_guard, rules)?; Some(Expression::new_logical_expression( source.span(), left, @@ -156,7 +161,7 @@ impl<'a> Preparation<'_, 'a> { let mut array = array.clone_in(builder.allocator()); for element in &mut array.elements { if let Some(value) = element.as_expression_mut() { - *value = self.value(value, guard)?; + *value = self.value(value, guard, rules)?; } else if matches!(element, ArrayExpressionElement::SpreadElement(_)) { return None; } diff --git a/libs/extractor/src/extractor/styled_record_value_tests.rs b/libs/extractor/src/extractor/styled_record_value_tests.rs new file mode 100644 index 000000000..04eee8b0d --- /dev/null +++ b/libs/extractor/src/extractor/styled_record_value_tests.rs @@ -0,0 +1,27 @@ +use super::static_lookup_value; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case("null", true, true)] +#[case("({color:null, nested:{color:'red'}})", true, true)] +#[case("({color:read()})", true, false)] +#[case("({nested:{color:'red'}})", false, false)] +#[case("({color:'red'})", false, true)] +#[case("read()", false, false)] +fn finite_lookup_when_values_are_literal_data_is_safe_to_select( + #[case] source: &str, + #[case] rules: bool, + #[case] expected: bool, +) { + let allocator = Allocator::default(); + let expression = Parser::new(&allocator, source, SourceType::tsx()) + .parse_expression() + .unwrap_or_else(|error| panic!("{error:?}")); + + let actual = static_lookup_value(&expression, rules); + + assert_eq!(actual, expected, "{source}"); +} diff --git a/libs/extractor/src/factory_dynamic_value_tests.rs b/libs/extractor/src/factory_dynamic_value_tests.rs index 0aa910f9e..e0f086746 100644 --- a/libs/extractor/src/factory_dynamic_value_tests.rs +++ b/libs/extractor/src/factory_dynamic_value_tests.rs @@ -2,6 +2,40 @@ use super::*; const FILE: &str = "/src/factory-dynamic.tsx"; +#[rstest] +#[case::before("...raw, bg:'red', other:OWN")] +#[case::after("bg:'red', other:OWN, ...raw")] +#[case::opaque_literal("bg:'red', other:OWN, ...{bg:'green', ...raw}")] +#[case::computed("bg:'red', other:OWN, ...raw, [key]:'blue'")] +#[serial] +fn compiled_factory_values_when_spreads_are_opaque_keep_explicit_styles_independent( + #[case] props: &str, + #[values(false, true)] namespace: bool, + #[values(false, true)] single_css: bool, +) { + // Given + let _state = crate::test_state::TestStateGuard::new(); + let call = if namespace { "ui.css" } else { "i" }; + let props = props.replace("OWN", &format!("held({call}({{overflow:'hidden'}}))")); + let source = format!( + "{} import {{jsx as o}} from 'react/jsx-runtime'; function render(raw,key,trace){{ const held=x=>(trace.push('held'),x); return o(t, (({{{props}}}) as const)); }}", + imports(namespace) + ); + // When + let output = extract_factory(&source, single_css); + // Then: the opaque keys are DOM data, not styling inputs; the utility runs at build time. + let projection = "(() => {const result=render({bg:'blue',other:'raw',_hover:'opaque'},'bg',trace);trace.push(result.target,result.props.bg,result.props.other,result.props._hover);return result;})()"; + assert_eq!( + held_observation(&output, projection, single_css), + "[[\"held\",\"div\",\"blue\",\"raw\",\"opaque\"],[\"hidden\"]]" + ); + let backgrounds: Vec<_> = output.styles.iter().filter_map(|style| match style { + ExtractStyleValue::Static(value) if value.property() == "background" => Some(value.value()), + _ => None, + }).collect(); + assert_eq!(backgrounds, ["red"]); +} + fn imports(namespace: bool) -> &'static str { if namespace { "import {Box as t} from '@devup-ui/react'; import * as ui from '@devup-ui/react';" @@ -22,6 +56,34 @@ fn extract_factory(source: &str, single_css: bool) -> ExtractOutput { .unwrap_or_else(|error| panic!("{source}\n{error}")) } +fn render_observation(output: &ExtractOutput, invocation: &str, filename: Option<&str>) -> String { + use oxc_allocator::TakeIn; + use oxc_ast_visit::{VisitMut, walk_mut::walk_expression}; + + struct Syntax<'a>(AstBuilder<'a>); + impl<'a> VisitMut<'a> for Syntax<'a> { + fn visit_expression(&mut self, expression: &mut Expression<'a>) { + let inner = crate::utils::unwrap_syntax_only_mut(expression); + let value = inner.take_in(&self.0); + *expression = value; + walk_expression(self, expression); + } + } + + let arena = Allocator::default(); + let mut parsed = Parser::new(&arena, &output.code, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + Syntax(AstBuilder::new(&arena)).visit_program(&mut parsed.program); + let javascript = ExtractOutput { + styles: output.styles.clone(), + code: Codegen::new().build(&parsed.program).code, + map: None, + css_file: output.css_file.clone(), + dependencies: output.dependencies.clone(), + }; + super::render_observation(&javascript, invocation, filename) +} + fn held_observation(output: &ExtractOutput, invocation: &str, single_css: bool) -> String { let filename = (!single_css).then_some(FILE); let variables: Vec<(String, String)> = output @@ -61,7 +123,7 @@ fn held_observation(output: &ExtractOutput, invocation: &str, single_css: bool) #[case::member_array(("other:([OWN,OWN])[0]", "[]", 1))] #[case::member_dynamic_array(("other:([OWN,OWN])[index]", "[]", 1))] #[case::member_spread(("other:({ ...p,a:OWN })[key]", "[]", 1))] -#[case::array_spread(("other:([OWN,...p])[index]", "[]", 2))] +#[case::array_spread(("other:([OWN,...p])[index]", "[]", 1))] #[case::wrapper(("other:(({ ...p,a:OWN }) as const)[key]", "[]", 1))] #[case::static_object(("other:({ a:OWN })['a']", "[]", 1))] #[case::shorthand(("mx:OWN", "[]", 2))] diff --git a/libs/extractor/src/gen_class_name.rs b/libs/extractor/src/gen_class_name.rs index 3b274b7fd..929367173 100644 --- a/libs/extractor/src/gen_class_name.rs +++ b/libs/extractor/src/gen_class_name.rs @@ -165,11 +165,7 @@ fn gen_class_name<'a>( false, ast_builder, )); - if let Expression::Identifier(_) = &expression { - Some(convert_class_name(ast_builder, &exp)) - } else { - Some(exp) - } + Some(convert_class_name(ast_builder, &exp)) } } } diff --git a/libs/extractor/src/gen_style.rs b/libs/extractor/src/gen_style.rs index 85b2f086d..ec2efb64b 100644 --- a/libs/extractor/src/gen_style.rs +++ b/libs/extractor/src/gen_style.rs @@ -60,7 +60,18 @@ fn push_one_sided_conditional<'a>( for p in gen_style(ast_builder, styles, filename) { if let ObjectPropertyKind::ObjectProperty(p) = p { let value = p.value.clone_in(ast_builder.allocator()); - let undefined = Expression::new_identifier(SPAN, "undefined", ast_builder); + let undefined = Expression::new_unary_expression( + SPAN, + oxc_ast::ast::UnaryOperator::Void, + Expression::new_numeric_literal( + SPAN, + 0.0, + None, + oxc_ast::ast::NumberBase::Decimal, + ast_builder, + ), + ast_builder, + ); let (consequent, alternate) = if value_when_true { (value, undefined) } else { @@ -355,7 +366,7 @@ mod tests { let generated = gen_styles(&builder, &styles, None).unwrap(); let code = expression_to_code(&generated); - assert!(code.contains("enabled?undefined:fallbackColor")); + assert!(code.contains("enabled?void 0:fallbackColor")); } #[test] diff --git a/libs/extractor/src/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 89fc2d516..ab5f87dda 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -31,9 +31,15 @@ use crate::extractor::extract_style_from_expression::{ use crate::stylex::StylexFunction; use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; +mod initialization; +mod lexical; #[cfg(test)] #[path = "imported_constants_px_tests.rs"] mod px_tests; +#[cfg(test)] +mod require_tests; +#[cfg(test)] +mod safety_tests; #[derive(Clone, Debug)] enum Constant { @@ -131,7 +137,11 @@ impl Constant { fn js_literal(&self) -> Option { match self { Self::String(text) => serde_json::to_string(text).ok(), - Self::Number(number) => Some(crate::utils::js_number_string(*number)), + Self::Number(number) => Some(if number.to_bits() == (-0.0_f64).to_bits() { + "-0".to_string() + } else { + crate::utils::js_number_string(*number) + }), Self::Null => Some("null".to_string()), Self::Bool(value) => Some(value.to_string()), Self::Undefined => Some("undefined".to_string()), @@ -211,6 +221,7 @@ pub(crate) enum ChangeSite { /// other modules, by the name the program binds them to #[derive(Default)] pub(crate) struct Inlined { + pub errors: Vec<(u32, String)>, pub dependencies: BTreeSet, pub stylex_vars: FxHashMap>, pub stylex_themes: FxHashMap, @@ -488,13 +499,39 @@ fn inline_in<'a>( style: &style, css_props: &css_props, names: FxHashSet::default(), + symbols: FxHashSet::default(), + references: FxHashSet::default(), depth: 0, class_names: Vec::new(), }; read.visit_program(program); - if read.names.is_empty() { + let (declarations, copies) = lexical::declarations(ast_builder, program, scoping); + for reference in &copies { + read.symbols + .extend(scoping.get_reference(*reference).symbol_id()); + } + loop { + let before = read.symbols.len(); + for (symbol, init) in &declarations { + if read.symbols.contains(symbol) { + read.reading(true, |read| read.visit_expression(init)); + } + } + if read.symbols.len() == before { + break; + } + } + if read.names.is_empty() && read.symbols.is_empty() { return Inlined::default(); } + let initialization = initialization::Initialization::new(program, scoping); + let mut inlined = Inlined { + errors: initialization.errors(&read.references, scoping), + ..Inlined::default() + }; + if !inlined.errors.is_empty() { + return inlined; + } let names = read.names; let mut modules = Modules { resolver, @@ -503,7 +540,6 @@ fn inline_in<'a>( loading: Vec::new(), }; let mut symbols: FxHashMap = FxHashMap::default(); - let mut inlined = Inlined::default(); let reads_math = { let mut scope = ModuleScope::new(filename, program, None); scope.style_names.extend( @@ -575,7 +611,7 @@ fn inline_in<'a>( // Constants are only worth reading when a style reads a name that may // hold one let reads_math = names.contains("Math") && !scope.binds("Math"); - if !reads_math && !names.iter().any(|name| scope.binds(name)) { + if !reads_math && read.symbols.is_empty() && !names.iter().any(|name| scope.binds(name)) { return Inlined::default(); } for name in &names { @@ -632,10 +668,107 @@ fn inline_in<'a>( reads_math }; inlined.dependencies = modules.exports.into_keys().collect(); + let mut pending = declarations; + pending.retain(|symbol, _| { + (read.symbols.contains(symbol) + || scoping + .get_resolved_reference_ids(*symbol) + .iter() + .any(|id| copies.contains(id))) + && scoping.symbol_scope_id(*symbol) != scoping.root_scope_id() + }); + loop { + let inline = Inline { + ast_builder, + scoping, + initialization: &initialization, + symbols: &symbols, + style: &style, + css_props: &css_props, + css_failures: &mut inlined.css_failures, + unknown: &mut inlined.unknown, + objects: false, + styles: false, + px: false, + position: InlinePosition::Rules, + marks: px, + class_names: Vec::new(), + }; + let resolved: Vec<_> = pending + .iter() + .filter_map(|(symbol, init)| { + let value = if scoping + .get_resolved_reference_ids(*symbol) + .iter() + .all(|id| copies.contains(id)) + { + inline.operand(init).or_else(|| { + let Expression::ObjectExpression(object) = + crate::utils::unwrap_syntax_only(init) + else { + return None; + }; + let entries: Option> = object + .properties + .iter() + .map(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { + return None; + }; + if property.kind != oxc_ast::ast::PropertyKind::Init + || property.method + { + return None; + } + let key = crate::utils::get_str_by_property_key(&property.key)? + .into_owned(); + if key == "__proto__" && !property.computed { + return None; + } + let value = inline.operand(&property.value)?; + matches!( + value, + Constant::String(_) + | Constant::Number(_) + | Constant::Null + | Constant::Bool(_) + ) + .then_some((key, value)) + }) + .collect(); + Some(Constant::Record(Rc::new(entries?))) + })? + } else { + inline.operand(init)? + }; + if matches!(&value, Constant::Number(number) if !number.is_finite()) { + return None; + } + matches!( + value, + Constant::String(_) + | Constant::Number(_) + | Constant::Null + | Constant::Bool(_) + | Constant::Undefined + | Constant::Record(_) + ) + .then_some((*symbol, value)) + }) + .collect(); + if resolved.is_empty() { + break; + } + for (symbol, value) in resolved { + pending.remove(&symbol); + symbols.insert(symbol, value); + } + } if !symbols.is_empty() || reads_math { Inline { ast_builder, scoping, + initialization: &initialization, symbols: &symbols, style: &style, css_props: &css_props, @@ -831,6 +964,8 @@ struct StyleReads<'s> { style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, names: FxHashSet, + symbols: FxHashSet, + references: FxHashSet, depth: usize, /// The bindings the `` child functions around take `css` and /// `cx` by @@ -848,8 +983,13 @@ impl StyleReads<'_> { impl<'a> Visit<'a> for StyleReads<'_> { fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { - if self.depth > 0 && reads_top_level(self.style.scoping, identifier) { - self.names.insert(identifier.name.to_string()); + if self.depth > 0 { + self.references.extend(identifier.reference_id.get()); + self.symbols + .extend(binding_of(self.style.scoping, identifier)); + if reads_top_level(self.style.scoping, identifier) { + self.names.insert(identifier.name.to_string()); + } } } @@ -1237,6 +1377,11 @@ struct ModuleScope<'p, 'a> { source: Option<&'p str>, locals: FxHashMap, declarations: FxHashMap>, + enums: FxHashMap>, + enum_members: Option<( + &'p oxc_ast::ast::TSEnumDeclaration<'a>, + FxHashMap, + )>, active: FxHashSet, resolving_css: bool, imports: FxHashMap, @@ -1264,6 +1409,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { source, locals: FxHashMap::default(), declarations: FxHashMap::default(), + enums: FxHashMap::default(), + enum_members: None, active: FxHashSet::default(), resolving_css: false, imports: FxHashMap::default(), @@ -1467,8 +1614,13 @@ impl<'p, 'a> ModuleScope<'p, 'a> { fn binds(&self, name: &str) -> bool { self.declarations.contains_key(name) + || self.enums.contains_key(name) || self.imports.contains_key(name) || self.locals.contains_key(name) + || self + .semantic_scoping() + .get_root_binding(name.into()) + .is_some() } fn is_global_math(&self, expression: &Expression<'_>) -> bool { @@ -1476,37 +1628,46 @@ impl<'p, 'a> ModuleScope<'p, 'a> { if identifier.name == "Math" && !self.binds("Math")) } - /// Record the members of an enum whose values are literals, up to the - /// first that is computed, returning its name - fn declare_enum(&mut self, declaration: &oxc_ast::ast::TSEnumDeclaration<'_>) -> String { + /// Record an enum for lazy evaluation after module bindings are collected. + fn declare_enum(&mut self, declaration: &'p oxc_ast::ast::TSEnumDeclaration<'a>) -> String { let name = declaration.id.name.to_string(); - if declaration.declare { - return name; + if !declaration.declare { + self.enums.insert(name.clone(), declaration); } + name + } + + fn evaluate_enum( + &mut self, + modules: &mut Modules<'_>, + declaration: &'p oxc_ast::ast::TSEnumDeclaration<'a>, + ) -> Constant { + let name = declaration.id.name.to_string(); let mut members = FxHashMap::default(); let mut next = Some(0.0); + let outer = self.enum_members.take(); for member in &declaration.body.members { + self.locals + .insert(name.clone(), Constant::Object(Rc::new(members.clone()))); + self.enum_members = Some((declaration, members.clone())); let value = match &member.initializer { None => next.map(Constant::Number), - Some(Expression::StringLiteral(literal)) => { - Some(Constant::String(literal.value.to_string())) - } - Some(initializer) => { - crate::utils::js_number_literal(initializer).map(Constant::Number) - } + Some(initializer) => self.evaluate(modules, initializer), }; - let Some(value) = value else { + let Some(value @ (Constant::String(_) | Constant::Number(_))) = value else { break; }; + if matches!(&value, Constant::Number(number) if !number.is_finite()) { + break; + } next = match &value { Constant::Number(number) => Some(number + 1.0), _ => None, }; members.insert(member.id.static_name().to_string(), value); } - self.locals - .insert(name.clone(), Constant::Object(Rc::new(members))); - name + self.enum_members = outer; + Constant::Object(Rc::new(members)) } fn import(&mut self, import: &oxc_ast::ast::ImportDeclaration<'_>) { @@ -1537,7 +1698,13 @@ impl<'p, 'a> ModuleScope<'p, 'a> { else { continue; }; - if callee.name != "require" { + let scoping = self.semantic_scoping(); + if callee.name != "require" + || callee + .reference_id + .get() + .is_none_or(|reference| scoping.get_reference(reference).symbol_id().is_some()) + { continue; } let source = source.value.to_string(); @@ -1596,6 +1763,11 @@ impl<'p, 'a> ModuleScope<'p, 'a> { if let Some(constant) = self.locals.get(name) { return Some(constant.clone()); } + if let Some(declaration) = self.enums.remove(name) { + let value = self.evaluate_enum(modules, declaration); + self.locals.insert(name.to_string(), value.clone()); + return Some(value); + } if self.active.contains(name) { return self .resolving_css @@ -1649,16 +1821,20 @@ impl<'p, 'a> ModuleScope<'p, 'a> { /// Whether `identifier` reads a binding of the module's top level, where /// the imports bind, and not a local of a function or block fn reads_top_level_binding(&self, identifier: &IdentifierReference<'_>) -> bool { - let scoping = self.shared_scoping.unwrap_or_else(|| { + let scoping = self.semantic_scoping(); + binding_of(scoping, identifier) + .is_some_and(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) + } + + fn semantic_scoping(&self) -> &Scoping { + self.shared_scoping.unwrap_or_else(|| { self.scoping.get_or_init(|| { SemanticBuilder::new() .build(self.program) .semantic .into_scoping() }) - }); - binding_of(scoping, identifier) - .is_some_and(|symbol| scoping.symbol_scope_id(symbol) == scoping.root_scope_id()) + }) } /// A value `StyleX` gives when this module's own extraction reads it, with @@ -1805,9 +1981,14 @@ impl<'p, 'a> ModuleScope<'p, 'a> { Expression::NullLiteral(_) => Some(Constant::Null), Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), Expression::Identifier(identifier) - if identifier.name == "undefined" && !self.binds("undefined") => + if matches!(identifier.name.as_str(), "undefined" | "NaN" | "Infinity") + && !self.binds(&identifier.name) => { - Some(Constant::Undefined) + Some(match identifier.name.as_str() { + "NaN" => Constant::Number(f64::NAN), + "Infinity" => Constant::Number(f64::INFINITY), + _ => Constant::Undefined, + }) } Expression::ObjectExpression(object) => Some(self.object(modules, object)), Expression::ArrayExpression(array) => { @@ -1829,9 +2010,24 @@ impl<'p, 'a> ModuleScope<'p, 'a> { } Expression::ComputedMemberExpression(member) => { let key = js_string(&self.evaluate(modules, &member.expression)?)?; + if self.is_global_math(&member.object) { + return math_constant(&key); + } member_of(&self.evaluate(modules, &member.object)?, &key) } - Expression::Identifier(identifier) => self.lookup(modules, &identifier.name), + Expression::Identifier(identifier) => { + if let Some((declaration, members)) = &self.enum_members + && declaration.span.contains_inclusive(identifier.span) + && declaration + .body + .members + .iter() + .any(|member| member.id.static_name() == identifier.name) + { + return members.get(identifier.name.as_str()).cloned(); + } + self.lookup(modules, &identifier.name) + } Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { math_constant(member.property.name.as_str()) } @@ -1839,22 +2035,23 @@ impl<'p, 'a> ModuleScope<'p, 'a> { &self.evaluate(modules, &member.object)?, member.property.name.as_str(), ), - Expression::CallExpression(call) => match &call.callee { - Expression::StaticMemberExpression(callee) - if self.is_global_math(&callee.object) => + Expression::CallExpression(call) => { + if let Some(name) = math_member(&call.callee, &|object| self.is_global_math(object)) { let mut arguments = Vec::with_capacity(call.arguments.len()); for argument in &call.arguments { arguments.push(self.evaluate(modules, argument.as_expression()?)?); } - fold_math(callee.property.name.as_str(), &arguments) + return fold_math(&name, &arguments); } - callee if self.is_style_api(modules, callee) => Some( - self.css_styles(modules, call) - .unwrap_or(Constant::Style(None)), - ), - _ => self.evaluate_stylex(modules, call), - }, + if self.is_style_api(modules, &call.callee) { + return Some( + self.css_styles(modules, call) + .unwrap_or(Constant::Style(None)), + ); + } + self.evaluate_stylex(modules, call) + } Expression::TaggedTemplateExpression(tagged) if self.is_style_api(modules, &tagged.tag) => { @@ -1984,61 +2181,50 @@ fn fold_template( } fn math_constant(name: &str) -> Option { - use std::f64::consts; - let value = match name { - "PI" => consts::PI, - "E" => consts::E, - "LN2" => consts::LN_2, - "LN10" => consts::LN_10, - "LOG2E" => consts::LOG2_E, - "LOG10E" => consts::LOG10_E, - "SQRT2" => consts::SQRT_2, - "SQRT1_2" => consts::FRAC_1_SQRT_2, - _ => return None, - }; - Some(Constant::Number(value)) + crate::build_time_values::exact_math::evaluate(name, None).map(Constant::Number) +} + +fn math_member<'a>( + expression: &Expression<'a>, + is_math: &dyn Fn(&Expression<'a>) -> bool, +) -> Option { + match expression { + Expression::StaticMemberExpression(member) if is_math(&member.object) => { + Some(member.property.name.to_string()) + } + Expression::ComputedMemberExpression(member) if is_math(&member.object) => { + crate::utils::get_string_by_literal_expression(&member.expression) + .map(std::borrow::Cow::into_owned) + } + _ => None, + } } /// `Math.{name}(...arguments)`, folded only where every engine computes the /// same result, so the CSS never depends on the platform that builds it fn fold_math(name: &str, arguments: &[Constant]) -> Option { - let mut numbers = Vec::with_capacity(arguments.len()); - for argument in arguments { - let Constant::Number(number) = argument else { + use crate::build_time_values::exact_math::{Operand, evaluate}; + if name == "pow" { + let (Some(Constant::Number(base)), Some(Constant::Number(exponent))) = + (arguments.first(), arguments.get(1)) + else { return None; }; - numbers.push(*number); - } - let first = numbers.first().copied(); - let value = match name { - "abs" => first?.abs(), - "ceil" => first?.ceil(), - "floor" => first?.floor(), - "trunc" => first?.trunc(), - "sqrt" => first?.sqrt(), - "sign" => { - let x = first?; - if x > 0.0 { - 1.0 - } else if x < 0.0 { - -1.0 - } else { - x - } - } - // JavaScript rounds a half up, toward +Infinity, where Rust rounds it - // away from zero; `x - floor(x)` is exact for every double - "round" => { - let x = first?; - let floor = x.floor(); - if x - floor >= 0.5 { floor + 1.0 } else { floor } - } - "max" => numbers.iter().copied().fold(f64::NEG_INFINITY, f64::max), - "min" => numbers.iter().copied().fold(f64::INFINITY, f64::min), - "pow" => exact_power(first?, *numbers.get(1)?)?, - _ => return None, - }; - value.is_finite().then_some(Constant::Number(value)) + return exact_power(*base, *exponent).map(Constant::Number); + } + let mut operands = Vec::with_capacity(arguments.len()); + for argument in arguments { + let operand = match argument { + Constant::Number(number) => Operand::Number(*number), + Constant::String(text) => Operand::String(text), + Constant::Bool(value) => Operand::Bool(*value), + Constant::Null => Operand::Null, + Constant::Undefined => Operand::Undefined, + _ => return None, + }; + operands.push(operand); + } + evaluate(name, Some(&operands)).map(Constant::Number) } /// An integer raised to a whole power, when the result is an exact integer @@ -2085,6 +2271,7 @@ enum InlinePosition { struct Inline<'s, 'a> { ast_builder: &'s AstBuilder<'a>, scoping: &'s Scoping, + initialization: &'s initialization::Initialization, symbols: &'s FxHashMap, style: &'s StyleSymbols<'s>, css_props: &'s CssTakers<'s>, @@ -2111,6 +2298,9 @@ impl<'a> Inline<'_, 'a> { Expression::Identifier(identifier) => { let reference = identifier.reference_id.get()?; let symbol = self.scoping.get_reference(reference).symbol_id()?; + if !self.initialization.allows(identifier) { + return None; + } self.symbols.get(&symbol).cloned() } Expression::StaticMemberExpression(member) if self.is_global_math(&member.object) => { @@ -2121,30 +2311,23 @@ impl<'a> Inline<'_, 'a> { member.property.name.as_str(), ), Expression::CallExpression(call) => { - let Expression::StaticMemberExpression(callee) = &call.callee else { - return None; - }; - if !self.is_global_math(&callee.object) { - return None; - } + let name = math_member(&call.callee, &|object| self.is_global_math(object))?; let arguments: Option> = call .arguments .iter() .map(|argument| self.operand(argument.as_expression()?)) .collect(); - fold_math(callee.property.name.as_str(), &arguments?) + fold_math(&name, &arguments?) } Expression::ComputedMemberExpression(member) => { let key = js_string(&self.operand(&member.expression)?)?; + if self.is_global_math(&member.object) { + return math_constant(&key); + } member_of(&self.constant(&member.object)?, &key) } // Folded only when they read a constant, leaving other code as written - Expression::TemplateLiteral(template) - if template - .expressions - .iter() - .any(|e| self.constant(e).is_some()) => - { + Expression::TemplateLiteral(template) => { let values: Option> = template .expressions .iter() @@ -2152,16 +2335,11 @@ impl<'a> Inline<'_, 'a> { .collect(); fold_template(template, &values?) } - Expression::BinaryExpression(binary) - if self.constant(&binary.left).is_some() - || self.constant(&binary.right).is_some() => - { - fold_binary( - binary.operator, - &self.operand(&binary.left)?, - &self.operand(&binary.right)?, - ) - } + Expression::BinaryExpression(binary) => fold_binary( + binary.operator, + &self.operand(&binary.left)?, + &self.operand(&binary.right)?, + ), Expression::UnaryExpression(unary) if unary.operator == oxc_syntax::operator::UnaryOperator::UnaryNegation => { @@ -2171,6 +2349,8 @@ impl<'a> Inline<'_, 'a> { } } Expression::ParenthesizedExpression(inner) => self.constant(&inner.expression), + Expression::TSAsExpression(inner) => self.operand(&inner.expression), + Expression::TSSatisfiesExpression(inner) => self.operand(&inner.expression), _ => None, } } @@ -2181,6 +2361,16 @@ impl<'a> Inline<'_, 'a> { Expression::StringLiteral(literal) => Some(Constant::String(literal.value.to_string())), Expression::BooleanLiteral(literal) => Some(Constant::Bool(literal.value)), Expression::NullLiteral(_) => Some(Constant::Null), + Expression::Identifier(identifier) + if binding_of(self.scoping, identifier).is_none() => + { + match identifier.name.as_str() { + "undefined" => Some(Constant::Undefined), + "NaN" => Some(Constant::Number(f64::NAN)), + "Infinity" => Some(Constant::Number(f64::INFINITY)), + _ => None, + } + } _ => crate::utils::js_number_literal(expression).map(Constant::Number), }) } @@ -2344,7 +2534,7 @@ fn constant_literal<'a>( None, builder, )), - Constant::Number(value) => Some(Expression::new_numeric_literal( + Constant::Number(value) if value.is_finite() => Some(Expression::new_numeric_literal( SPAN, *value, None, @@ -2368,7 +2558,7 @@ fn constant_literal<'a>( constant_literal(builder, value, objects)?, false, false, - false, + key == "__proto__", builder, )); } @@ -2386,6 +2576,22 @@ fn constant_literal<'a>( } impl<'a> VisitMut<'a> for Inline<'_, 'a> { + fn visit_object_property_kind(&mut self, property: &mut ObjectPropertyKind<'a>) { + if let ObjectPropertyKind::SpreadProperty(spread) = property + && !self.styles + && let Expression::Identifier(identifier) = + crate::utils::unwrap_syntax_only(&spread.argument) + && binding_of(self.scoping, identifier).is_some_and(|symbol| { + self.scoping.symbol_scope_id(symbol) != self.scoping.root_scope_id() + }) + && let Some(value @ Constant::Record(_)) = self.constant(&spread.argument) + && let Some(literal) = constant_literal(self.ast_builder, &value, true) + { + spread.argument = literal; + } else { + walk_mut::walk_object_property_kind(self, property); + } + } fn visit_expression(&mut self, expression: &mut Expression<'a>) { if self.styles { if let Some(constant) = self.constant(expression) { @@ -2592,7 +2798,17 @@ impl<'a> VisitMut<'a> for Inline<'_, 'a> { } } +#[cfg(test)] +mod exact_edge_tests; +#[cfg(test)] +mod exact_math_tests; +#[cfg(test)] +mod exact_tests; +#[cfg(test)] +mod numeric_semantics_tests; #[cfg(test)] mod scope_tests; #[cfg(test)] mod stylex_scope_tests; +#[cfg(test)] +mod tdz_tests; diff --git a/libs/extractor/src/imported_constants/exact_edge_tests.rs b/libs/extractor/src/imported_constants/exact_edge_tests.rs new file mode 100644 index 000000000..c51bd3a84 --- /dev/null +++ b/libs/extractor/src/imported_constants/exact_edge_tests.rs @@ -0,0 +1,57 @@ +use super::exact_tests::{extracted, static_values}; + +#[test] +#[serial_test::serial] +fn math_members_when_computed_names_are_literal() { + for imported in [false, true] { + let declaration = if imported { + "import { N, C } from './values';" + } else { + "const N = Math['imul'](3, 4); const C = Math['PI'];" + }; + let output = extracted(&format!("import {{ Box }} from '@devup-ui/react'; {declaration} export const view = ;"), "export const N = Math['imul'](3, 4); export const C = Math['PI'];").unwrap_or_else(|error| panic!("math_members_when_computed_names_are_literal: {error}")); + let values = static_values(&output); + assert!(values.contains(&"12".to_string()), "{values:?}"); + assert!( + values.contains(&"3.141592653589793".to_string()), + "{values:?}" + ); + } +} + +#[test] +#[serial_test::serial] +fn math_members_when_names_or_callable_values_are_unknown() { + for expression in [ + "Math[key](1)", + "Math.abs", + "Math.PI(1)", + "Math['random']()", + "Math['UNKNOWN']", + ] { + assert!(extracted("import { css } from '@devup-ui/react'; import { N } from './values'; css({ zIndex: N });", &format!("export const N = {expression};")).is_err(), "{expression}"); + } +} + +#[test] +#[serial_test::serial] +fn imported_values_when_nonfinite_numbers_stay_dynamic() { + let output = extracted("import { Box } from '@devup-ui/react'; import { N } from './values'; export const view = ;", "export const N = Infinity;").unwrap_or_else(|error| panic!("imported_values_when_nonfinite_numbers_stay_dynamic: {error}")); + assert!(output.code.contains("--"), "{}", output.code); +} + +#[test] +#[serial_test::serial] +fn nested_constants_when_undefined_and_negative_numbers_select_styles() { + let output = extracted("import { Box } from '@devup-ui/react'; export function f() { const empty = undefined; const n = -3; const alias = -n; const object = { value: 8 }; return ; }", "").unwrap_or_else(|error| panic!("nested_constants_when_undefined_and_negative_numbers_select_styles: {error}")); + let values = static_values(&output); + assert!(values.contains(&"3".to_string()), "{values:?}"); + assert!(values.contains(&"green".to_string()), "{values:?}"); +} + +#[test] +#[serial_test::serial] +fn enums_when_prior_siblings_include_exact_math() { + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.C });", "enum Other { A = 4, B = A + 1 } export enum E { A = Math.imul(2, 3), B = Other.B + A, C = E.B + 1 }").unwrap_or_else(|error| panic!("enums_when_prior_siblings_include_exact_math: {error}")); + assert_eq!(static_values(&output), vec!["12".to_string()]); +} diff --git a/libs/extractor/src/imported_constants/exact_math_tests.rs b/libs/extractor/src/imported_constants/exact_math_tests.rs new file mode 100644 index 000000000..60f97628d --- /dev/null +++ b/libs/extractor/src/imported_constants/exact_math_tests.rs @@ -0,0 +1,69 @@ +use super::exact_tests::{extracted, static_values}; + +#[test] +#[serial_test::serial] +fn fn09_math_when_local_and_imported_values_are_exact() { + for (expression, expected) in [ + ("Math.abs(-2)", "2"), + ("Math.ceil(1.2)", "2"), + ("Math.floor(1.8)", "1"), + ("Math.round(-2.5)", "-2"), + ("Math.trunc(-1.8)", "-1"), + ("Math.sign(-2)", "-1"), + ("Math.max(1, 3, 2)", "3"), + ("Math.min(1, -3, 2)", "-3"), + ("Math.sqrt(4)", "2"), + ("Math.fround(16777217)", "16777216"), + ("Math.imul(4294967295, 5)", "-5"), + ("Math.clz32(0)", "32"), + ("Math.PI", "3.141592653589793"), + ("Math.E", "2.718281828459045"), + ("Math.LN2", "0.6931471805599453"), + ("Math.LN10", "2.302585092994046"), + ("Math.LOG2E", "1.4426950408889634"), + ("Math.LOG10E", "0.4342944819032518"), + ("Math.SQRT2", "1.4142135623730951"), + ("Math.SQRT1_2", "0.7071067811865476"), + ("Math.imul(2147483647, 2)", "-2"), + ("Math.imul(3.9, 2.9)", "6"), + ("Math.clz32(-1)", "0"), + ("Math.clz32(4294967297)", "31"), + ("Math.fround(0.1)", "0.10000000149011612"), + ("Math.round(-0.5)", "0"), + ("Math.sign(-0)", "0"), + ("Math.max(-0, 0)", "0"), + ("Math.min(0, -0)", "0"), + ("Math.imul('3.9', true)", "3"), + ("Math.clz32(null)", "32"), + ("Math.clz32(undefined)", "32"), + ("Math.clz32(NaN)", "32"), + ("Math.clz32(Infinity)", "32"), + ("Math.max('1', 2)", "2"), + ] { + for imported in [false, true] { + let module = format!("export const VALUE = {expression};"); + let binding = if imported { + "import { VALUE } from './values';".to_string() + } else { + format!("function compute() {{ return {expression}; }} const VALUE = compute();") + }; + let output = extracted(&format!("import {{ css }} from '@devup-ui/react'; {binding} export const s = css({{ zIndex: VALUE }});"), &module).unwrap_or_else(|error| panic!("{expression}, imported={imported}: {error}")); + assert_eq!( + static_values(&output) + .iter() + .map(|value| value + .parse::() + .unwrap_or_else(|error| panic!("parse f64: {error}")) + .to_bits()) + .collect::>(), + vec![ + expected + .parse::() + .unwrap_or_else(|error| panic!("parse expected f64: {error}")) + .to_bits() + ], + "{expression}, imported={imported}" + ); + } + } +} diff --git a/libs/extractor/src/imported_constants/exact_tests.rs b/libs/extractor/src/imported_constants/exact_tests.rs new file mode 100644 index 000000000..85d9f4382 --- /dev/null +++ b/libs/extractor/src/imported_constants/exact_tests.rs @@ -0,0 +1,215 @@ +use crate::{ExtractOption, ExtractOutput, ExtractStyleValue, ResolvedModule}; + +pub(super) fn extracted(code: &str, module: &str) -> Result { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let module = module.to_string(); + let resolver = move |specifier: &str, _: &str| { + (specifier == "./values").then(|| ResolvedModule { + path: "/src/values.ts".to_string(), + code: module.clone(), + }) + }; + crate::extract_with_modules( + "/src/App.tsx", + code, + ExtractOption { + import_main_css: false, + ..ExtractOption::default() + }, + false, + &resolver, + ) + .map_err(|error| error.to_string()) +} + +pub(super) fn static_values(output: &ExtractOutput) -> Vec { + output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some(style.value.clone()), + _ => None, + }) + .collect() +} + +#[test] +#[serial_test::serial] +fn fn08_computed_enum_when_preceding_members_are_exact() { + let module = "const STEP = 3; export enum E { A = 2, B = A + STEP, C = E.B + 1, D, Text = 'gr' + 'een' }"; + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; export const s = css({ zIndex: E.B, order: E.C, flexGrow: E.D, color: E.Text });", module).unwrap_or_else(|error| panic!("fn08_computed_enum_when_preceding_members_are_exact: {error}")); + let values = static_values(&output); + for expected in ["5", "6", "7", "green"] { + assert!(values.contains(&expected.to_string()), "{values:?}"); + } +} + +#[test] +#[serial_test::serial] +fn fn08_enum_when_a_member_is_uncertain_or_ambient() { + for module in [ + "export enum E { A = unknown(), B = A + 3, C = 8 }", + "export declare enum E { A = 2, B = 5 }", + "export enum E { A = B + 1, B = 5 }", + "const B = 8; export enum E { A = B + 1, B = 5 }", + "export enum E { A = 'green', B }", + ] { + assert!(extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.B });", module).is_err(), "{module}"); + } +} + +#[test] +#[serial_test::serial] +fn jsx12_primitives_when_lexical_bindings_share_names() { + let output = extracted("import { Box } from '@devup-ui/react'; const color = 'red'; export function f() { const color = 'green'; const n = Math.imul(2, 3); const alias = n + 1; observe(color, alias); return ; } export const g = () => { const color = 'blue'; return ; }; export const top = ;", "").unwrap_or_else(|error| panic!("jsx12_primitives_when_lexical_bindings_share_names: {error}")); + let values = static_values(&output); + for expected in ["red", "green", "blue", "7"] { + assert!(values.contains(&expected.to_string()), "{values:?}"); + } + assert!(!output.code.contains("--"), "{}", output.code); + assert!( + output.code.contains("observe(color, alias)"), + "{}", + output.code + ); +} + +#[test] +#[serial_test::serial] +fn jsx12_primitives_when_nested_scopes_read_imports_and_outer_constants() { + let output = extracted("import { Box } from '@devup-ui/react'; import { BASE } from './values'; const STEP = 2; export function f() { const n = BASE + STEP; { const n = 9; observe(n); } const value = (`${n}px` as const) satisfies string; return ; }", "export const BASE = 5;").unwrap_or_else(|error| panic!("jsx12_primitives_when_nested_scopes_read_imports_and_outer_constants: {error}")); + let mut values = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => Some((style.property(), style.value())), + _ => None, + }) + .collect::>(); + values.sort_unstable(); + assert_eq!(values, vec![("width", "7px"), ("z-index", "7")]); + assert_eq!(output.dependencies, vec!["/src/values.ts".to_string()]); +} + +#[test] +#[serial_test::serial] +fn jsx12_constants_when_reads_precede_initialization_or_write_the_binding() { + for body in [ + "const view = ; var color = 'red'; return view;", + "const color = 'red'; color = 'blue'; return ;", + "const a = compute(); const b = a; return ;", + ] { + let output = extracted( + &format!("import {{ Box }} from '@devup-ui/react'; export function f() {{ {body} }}"), + "", + ) + .unwrap_or_else(|error| { + panic!( + "jsx12_constants_when_reads_precede_initialization_or_write_the_binding: {error}" + ) + }); + assert!(output.code.contains("--"), "{body}: {}", output.code); + } +} + +#[test] +#[serial_test::serial] +fn fn09_math_when_results_or_functions_are_uncertain() { + for expression in [ + "Math.sin(1)", + "Math.random()", + "Math.sqrt(-1)", + "Math.max()", + "Math.min()", + "Math.fround(1e100)", + "Math.NOPE(1)", + "Math.imul(unknown(), 2)", + ] { + let module = format!("export const VALUE = {expression};"); + assert!(extracted("import { css } from '@devup-ui/react'; import { VALUE } from './values'; css({ zIndex: VALUE });", &module).is_err(), "{expression}"); + } + for binding in [ + "let Math = source;", + "function Math() {}", + "const Math = { imul: unknown };", + "const undefined = unknown;", + ] { + let module = format!("{binding} export const VALUE = Math.imul(undefined, 2);"); + assert!(extracted("import { css } from '@devup-ui/react'; import { VALUE } from './values'; css({ zIndex: VALUE });", &module).is_err(), "{binding}"); + } +} + +#[test] +fn fn09_signed_zero_when_exact_math_preserves_js_edges() { + use crate::build_time_values::exact_math::{Operand, evaluate}; + for (name, arguments, negative) in [ + ("round", vec![-0.5], true), + ("round", vec![0.5], false), + ("sign", vec![-0.0], true), + ("min", vec![0.0, -0.0], true), + ("max", vec![-0.0, 0.0], false), + ("fround", vec![-0.0], true), + ] { + let arguments: Vec<_> = arguments.into_iter().map(Operand::Number).collect(); + let result = + evaluate(name, Some(&arguments)).unwrap_or_else(|| panic!("evaluate {name} failed")); + assert_eq!(result.is_sign_negative(), negative, "{name}"); + } + for (name, arguments) in [ + ("max", vec![f64::NAN, 1.0]), + ("min", vec![1.0, f64::NAN]), + ("sign", vec![f64::NAN]), + ("round", vec![f64::INFINITY]), + ] { + let arguments: Vec<_> = arguments.into_iter().map(Operand::Number).collect(); + assert_eq!(evaluate(name, Some(&arguments)), None, "{name}"); + } + assert!(super::fold_math("abs", &[super::Constant::Object(std::rc::Rc::default())]).is_none()); +} + +#[test] +#[serial_test::serial] +fn fn08_exact_prefix_when_later_enum_members_are_uncertain() { + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.A });", "const A = 99; const OFFSET = A; export enum E { A = 2, B = A + OFFSET, C = unknown(), D = 8 }").unwrap_or_else(|error| panic!("fn08_exact_prefix_when_later_enum_members_are_uncertain (E.A): {error}")); + assert_eq!(static_values(&output), vec!["2".to_string()]); + let output = extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.B });", "const A = 99; const OFFSET = A; export enum E { A = 2, B = A + OFFSET }").unwrap_or_else(|error| panic!("fn08_exact_prefix_when_later_enum_members_are_uncertain (E.B): {error}")); + assert_eq!(static_values(&output), vec!["101".to_string()]); +} + +#[test] +#[serial_test::serial] +fn jsx12_primitive_only_when_no_top_level_value_is_read() { + let output = extracted("import { Box } from '@devup-ui/react'; export function f() { const color = 'green'; const n = 3 as const; const yes = true; const empty = null; return ; }", "").unwrap_or_else(|error| panic!("jsx12_primitive_only_when_no_top_level_value_is_read: {error}")); + let values = static_values(&output); + for expected in ["green", "3", "block"] { + assert!(values.contains(&expected.to_string()), "{values:?}"); + } + assert!(!output.code.contains("--"), "{}", output.code); +} + +#[test] +#[serial_test::serial] +fn jsx12_values_when_nonfinite_constants_are_not_style_literals() { + let output = extracted("import { Box } from '@devup-ui/react'; export function f() { const n = Infinity; return ; }", "").unwrap_or_else(|error| panic!("jsx12_values_when_nonfinite_constants_are_not_style_literals: {error}")); + assert!(output.code.contains("--"), "{}", output.code); + assert!(extracted("import { css } from '@devup-ui/react'; import { E } from './values'; css({ zIndex: E.B });", "export enum E { A = Infinity, B }").is_err()); +} + +#[test] +#[serial_test::serial] +fn jsx12_values_when_parameters_mutation_or_uncertainty_prevent_folding() { + for body in [ + "const color = Math.random();", + "let color = 'red'; color = 'blue';", + "const object = { color: 'red' }; object.color = 'blue'; const color = object.color;", + "const Math = source; const color = Math.imul(2, 3);", + "const color = Math.sin(1);", + "const color = parameter;", + ] { + let output = extracted(&format!("import {{ Box }} from '@devup-ui/react'; const color = 'green'; export function f(parameter) {{ {body} return ; }}"), "").unwrap_or_else(|error| panic!("jsx12_values_when_parameters_mutation_or_uncertainty_prevent_folding: {error}")); + assert!(output.code.contains("--"), "{body}: {}", output.code); + } + let output = extracted("import { Box } from '@devup-ui/react'; const color = 'red'; export function f(color) { return ; }", "").unwrap_or_else(|error| panic!("jsx12_values_when_parameters_mutation_or_uncertainty_prevent_folding (param): {error}")); + assert!(output.code.contains("--"), "{}", output.code); +} diff --git a/libs/extractor/src/imported_constants/initialization.rs b/libs/extractor/src/imported_constants/initialization.rs new file mode 100644 index 000000000..c4856a460 --- /dev/null +++ b/libs/extractor/src/imported_constants/initialization.rs @@ -0,0 +1,231 @@ +use oxc_ast::ast::{ + ArrowFunctionExpression, CallExpression, Class, ClassElement, Expression, Function, + IdentifierReference, Program, VariableDeclaration, +}; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::Scoping; +use oxc_span::{GetSpan, Span}; +use oxc_syntax::{reference::ReferenceId, scope::ScopeFlags, symbol::SymbolId}; +use rustc_hash::{FxHashMap, FxHashSet}; + +#[derive(Clone, Copy)] +enum Read { + Initialized, + Deferred, + Before { + temporal: bool, + at: u32, + symbol: SymbolId, + }, +} + +pub(super) struct Initialization { + reads: FxHashMap, +} + +impl Initialization { + pub(super) fn new(program: &Program<'_>, scoping: &Scoping) -> Self { + let mut collector = Collector { + scoping, + bindings: FxHashMap::default(), + references: Vec::new(), + frames: vec![Frame { + parent: 0, + deferred: false, + }], + frame: 0, + invoked: FxHashSet::default(), + }; + collector.visit_program(program); + let reads = collector + .references + .iter() + .map(|&(reference, symbol, at, frame)| { + let read = collector + .bindings + .get(&symbol) + .map_or(Read::Initialized, |binding| { + if collector.is_deferred(frame, binding.frame) { + Read::Deferred + } else if at < binding.initialized + && !binding + .class_initializers + .iter() + .any(|span| span.start <= at && at < span.end) + { + Read::Before { + temporal: binding.temporal, + at, + symbol, + } + } else { + Read::Initialized + } + }); + (reference, read) + }) + .collect(); + Self { reads } + } + + pub(super) fn allows(&self, identifier: &IdentifierReference<'_>) -> bool { + match identifier + .reference_id + .get() + .and_then(|reference| self.reads.get(&reference)) + { + Some(Read::Before { .. }) => false, + Some(Read::Initialized | Read::Deferred) | None => true, + } + } + + pub(super) fn errors( + &self, + references: &FxHashSet, + scoping: &Scoping, + ) -> Vec<(u32, String)> { + let mut errors: Vec<_> = references.iter().filter_map(|reference| { + let Read::Before { temporal: true, at, symbol } = self.reads.get(reference)? else { + return None; + }; + let name = scoping.symbol_name(*symbol); + Some((*at, format!("style value cannot use `{name}` at build time: it is read before its lexical declaration is initialized, so the original JavaScript throws ReferenceError; move the declaration of `{name}` above this use"))) + }).collect(); + errors.sort_unstable_by_key(|(at, _)| *at); + errors + } +} + +struct Binding { + initialized: u32, + class_initializers: Vec, + frame: usize, + temporal: bool, +} + +impl Binding { + /// Class self-bindings initialize after keys and extends, before static values. + fn class(class: &Class<'_>, frame: usize) -> Self { + let initialized = class.span.end; + let class_initializers = class + .body + .body + .iter() + .filter_map(|element| match element { + ClassElement::PropertyDefinition(property) if property.r#static => { + property.value.as_ref().map(GetSpan::span) + } + ClassElement::StaticBlock(block) => Some(block.span), + _ => None, + }) + .collect(); + Self { + initialized, + class_initializers, + frame, + temporal: true, + } + } +} + +struct Frame { + parent: usize, + deferred: bool, +} + +struct Collector<'s> { + scoping: &'s Scoping, + bindings: FxHashMap, + references: Vec<(ReferenceId, SymbolId, u32, usize)>, + frames: Vec, + frame: usize, + invoked: FxHashSet, +} + +impl Collector<'_> { + fn is_deferred(&self, mut from: usize, declaration: usize) -> bool { + while from != declaration && from != 0 { + let frame = &self.frames[from]; + if frame.deferred { + return true; + } + from = frame.parent; + } + false + } + + fn function(&mut self, start: u32, visit: impl FnOnce(&mut Self)) { + let outer = self.frame; + self.frame = self.frames.len(); + self.frames.push(Frame { + parent: outer, + deferred: !self.invoked.contains(&start), + }); + visit(self); + self.frame = outer; + } +} + +impl<'a> Visit<'a> for Collector<'_> { + fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { + for declarator in &declaration.declarations { + for identifier in declarator.id.get_binding_identifiers() { + if let Some(symbol) = identifier.symbol_id.get() { + self.bindings.insert( + symbol, + Binding { + initialized: declarator.span.end, + class_initializers: Vec::new(), + frame: self.frame, + temporal: declaration.kind.is_lexical(), + }, + ); + } + } + } + walk::walk_variable_declaration(self, declaration); + } + + fn visit_class(&mut self, class: &Class<'a>) { + if let Some(symbol) = class.id.as_ref().and_then(|id| id.symbol_id.get()) { + self.bindings + .insert(symbol, Binding::class(class, self.frame)); + } + walk::walk_class(self, class); + } + + fn visit_identifier_reference(&mut self, identifier: &IdentifierReference<'a>) { + if let Some(reference) = identifier.reference_id.get() + && self.scoping.get_reference(reference).is_read() + && let Some(symbol) = self.scoping.get_reference(reference).symbol_id() + { + self.references + .push((reference, symbol, identifier.span.start, self.frame)); + } + } + + fn visit_function(&mut self, function: &Function<'a>, flags: ScopeFlags) { + self.function(function.span.start, |collector| { + walk::walk_function(collector, function, flags); + }); + } + + fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'a>) { + self.function(arrow.span.start, |collector| { + walk::walk_arrow_function_expression(collector, arrow); + }); + } + + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + match crate::utils::unwrap_syntax_only(&call.callee) { + Expression::FunctionExpression(function) => { + self.invoked.insert(function.span.start); + } + Expression::ArrowFunctionExpression(arrow) => { + self.invoked.insert(arrow.span.start); + } + _ => {} + } + walk::walk_call_expression(self, call); + } +} diff --git a/libs/extractor/src/imported_constants/lexical.rs b/libs/extractor/src/imported_constants/lexical.rs new file mode 100644 index 000000000..550eae980 --- /dev/null +++ b/libs/extractor/src/imported_constants/lexical.rs @@ -0,0 +1,78 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{ + Expression, JSXSpreadAttribute, ObjectExpression, ObjectPropertyKind, Program, + VariableDeclaration, VariableDeclarationKind, +}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::Scoping; +use oxc_syntax::reference::ReferenceId; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::{FxHashMap, FxHashSet}; + +/// Const initializers keyed by lexical identity, excluding written bindings. +pub(super) fn declarations<'a>( + builder: &AstBuilder<'a>, + program: &Program<'a>, + scoping: &Scoping, +) -> (FxHashMap>, FxHashSet) { + let mut collector = Collector { + builder, + scoping, + found: FxHashMap::default(), + copies: FxHashSet::default(), + }; + collector.visit_program(program); + (collector.found, collector.copies) +} + +struct Collector<'s, 'a> { + builder: &'s AstBuilder<'a>, + scoping: &'s Scoping, + found: FxHashMap>, + copies: FxHashSet, +} + +impl<'a> Visit<'a> for Collector<'_, 'a> { + fn visit_jsx_spread_attribute(&mut self, spread: &JSXSpreadAttribute<'a>) { + if let Expression::Identifier(identifier) = + crate::utils::unwrap_syntax_only(&spread.argument) + { + self.copies.extend(identifier.reference_id.get()); + } + walk::walk_jsx_spread_attribute(self, spread); + } + fn visit_object_expression(&mut self, object: &ObjectExpression<'a>) { + for property in &object.properties { + if let ObjectPropertyKind::SpreadProperty(spread) = property + && let Expression::Identifier(identifier) = + crate::utils::unwrap_syntax_only(&spread.argument) + { + self.copies.extend(identifier.reference_id.get()); + } + } + walk::walk_object_expression(self, object); + } + fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) { + if declaration.kind == VariableDeclarationKind::Const { + for declarator in &declaration.declarations { + if let Some(identifier) = declarator.id.get_binding_identifier() + && let Some(symbol) = identifier.symbol_id.get() + && !self.scoping.get_resolved_reference_ids(symbol).is_empty() + && !self + .scoping + .get_resolved_reference_ids(symbol) + .iter() + .any(|reference| self.scoping.get_reference(*reference).is_write()) + && let Some(init) = &declarator.init + { + self.found.insert( + symbol, + init.clone_in_with_semantic_ids(self.builder.allocator()), + ); + } + } + } + walk::walk_variable_declaration(self, declaration); + } +} diff --git a/libs/extractor/src/imported_constants/numeric_semantics_tests.rs b/libs/extractor/src/imported_constants/numeric_semantics_tests.rs new file mode 100644 index 000000000..077f56db8 --- /dev/null +++ b/libs/extractor/src/imported_constants/numeric_semantics_tests.rs @@ -0,0 +1,84 @@ +use super::exact_tests::{extracted, static_values}; +use super::{Constant, constant_literal}; + +#[rstest::rstest] +#[case(("Math.sign(-0)", "green"))] +#[case(("Math.fround(-0)", "green"))] +#[case(("Math.min(0, -0)", "green"))] +#[case(("Math.sign(0)", "red"))] +#[case(("Math.fround(0)", "red"))] +#[case(("Math.max(-0, 0)", "red"))] +#[serial_test::serial] +fn computed_color_when_imported_zero_is_consumed( + #[case] scenario: (&str, &str), + #[values("Object.is(n, -0)", "1 / n === -Infinity")] predicate: &str, + #[values(false, true)] inline: bool, +) { + // Given + let (expression, expected) = scenario; + let module = format!("export const VALUE = {expression};"); + let consumer = if inline { + "export const s = css({ color: colorOf(VALUE) });" + } else { + "const color = colorOf(VALUE); export const s = css({ color });" + }; + let code = format!( + "import {{ css }} from '@devup-ui/react'; import {{ VALUE }} from './values'; + const colorOf = n => {predicate} ? 'green' : 'red'; {consumer}" + ); + + // When + let output = extracted(&code, &module).unwrap_or_else(|error| panic!("{code}: {error}")); + + // Then + assert_eq!( + static_values(&output), + vec![expected.to_string()], + "{expression}, {predicate}, inline={inline}" + ); +} + +#[rstest::rstest] +#[case("Math.sign(-0)")] +#[case("Math.fround(-0)")] +#[case("Math.min(0, -0)")] +#[case("Math.sign(0)")] +#[serial_test::serial] +fn canonical_css_zero_when_imported_zero_is_a_style_value(#[case] expression: &str) { + // Given + let module = format!("export const VALUE = {expression};"); + let code = "import { css } from '@devup-ui/react'; import { VALUE } from './values'; + export const s = css({ zIndex: VALUE });"; + + // When + let output = extracted(code, &module).unwrap_or_else(|error| panic!("{expression}: {error}")); + + // Then + assert_eq!(static_values(&output), vec!["0".to_string()]); +} + +#[rstest::rstest] +#[case(-0.0, "green")] +#[case(0.0, "red")] +#[serial_test::serial] +fn computed_color_when_scalar_ast_is_serialized(#[case] zero: f64, #[case] expected: &str) { + // Given + let allocator = oxc_allocator::Allocator::default(); + let builder = oxc_ast::builder::AstBuilder::new(&allocator); + let literal = constant_literal(&builder, &Constant::Number(zero), false) + .unwrap_or_else(|| panic!("finite scalar must have a literal")); + let mut codegen = oxc_codegen::Codegen::new(); + codegen.print_expression(&literal); + let code = format!( + "import {{ css }} from '@devup-ui/react'; + const colorOf = n => Object.is(n, -0) ? 'green' : 'red'; + export const s = css({{ color: colorOf({}) }});", + codegen.into_source_text() + ); + + // When + let output = extracted(&code, "").unwrap_or_else(|error| panic!("{code}: {error}")); + + // Then + assert_eq!(static_values(&output), vec![expected.to_string()]); +} diff --git a/libs/extractor/src/imported_constants/require_tests.rs b/libs/extractor/src/imported_constants/require_tests.rs new file mode 100644 index 000000000..86f9aff06 --- /dev/null +++ b/libs/extractor/src/imported_constants/require_tests.rs @@ -0,0 +1,98 @@ +use crate::{ExtractOption, ExtractStyleValue, ResolvedModule}; +use rstest::rstest; +use serial_test::serial; +use std::cell::RefCell; +use std::rc::Rc; + +fn require_probe(module: &str) -> (Vec, Result) { + css::class_map::reset_class_map(); + css::file_map::reset_file_map(); + let requested = Rc::new(RefCell::new(Vec::new())); + let recorded = requested.clone(); + let module = module.to_string(); + let resolver = move |specifier: &str, _: &str| { + recorded.borrow_mut().push(specifier.to_string()); + let source = match specifier { + "./values" => module.as_str(), + "./trap" => "export const color = 'green';", + "./local" => "export const require = () => ({ color: 'red' });", + _ => return None, + }; + Some(ResolvedModule { + path: format!("/src/{specifier}.ts"), + code: source.to_string(), + }) + }; + let output = crate::extract_with_modules("/src/App.tsx", "import {Box} from '@devup-ui/react';import {COLOR} from './values';export const a=;", ExtractOption::default(),false,&resolver).map_err(|error|error.to_string()); + (requested.borrow().clone(), output) +} + +#[rstest] +#[case( + "const require=()=>({color:'red'});const values=require('./trap');export const COLOR=values.color;" +)] +#[case( + "function require(){return {color:'red'};}const values=require('./trap');export const COLOR=values.color;" +)] +#[case("let require=load;const values=require('./trap');export const COLOR=values.color;")] +#[case("class require{}const values=require('./trap');export const COLOR=values.color;")] +#[case( + "import {require} from './local';const values=require('./trap');export const COLOR=values.color;" +)] +#[case("const values=require('./trap');const require=load;export const COLOR=values.color;")] +#[serial] +fn shadowed_require_when_reading_module_constants_never_resolves_a_module(#[case] module: &str) { + let (requested, output) = require_probe(module); + assert!( + !requested.iter().any(|specifier| specifier == "./trap"), + "{requested:?}" + ); + if let Ok(output) = output { + assert!( + !output.styles.iter().any( + |style| matches!(style,ExtractStyleValue::Static(style) if style.value=="green") + ), + "{}", + output.code + ); + } +} + +#[rstest] +#[case( + "export function f(require){const values=require('./trap');return values.color;}export const COLOR=f(local);" +)] +#[case( + "try{throw local;}catch(require){const values=require('./trap');observe(values);}export const COLOR=dynamic;" +)] +#[case( + "const require=local;{const values=require('./trap');observe(values);}export const COLOR=dynamic;" +)] +#[serial] +fn enclosing_require_when_nested_calls_are_read_never_loads_the_named_module(#[case] module: &str) { + let (requested, _) = require_probe(module); + assert!( + !requested.iter().any(|specifier| specifier == "./trap"), + "{requested:?}" + ); +} + +#[test] +#[serial] +fn unbound_require_when_reading_module_constants_still_loads() { + let (requested, output) = + require_probe("const values=require('./trap');export const COLOR=values.color;"); + let output = output.unwrap_or_else(|error| panic!("{error}")); + assert!( + requested.iter().any(|specifier| specifier == "./trap"), + "{requested:?}" + ); + assert!( + output + .styles + .iter() + .any(|style| matches!(style,ExtractStyleValue::Static(style) if style.value=="green")), + "{}", + output.code + ); +} diff --git a/libs/extractor/src/imported_constants/safety_tests.rs b/libs/extractor/src/imported_constants/safety_tests.rs new file mode 100644 index 000000000..fb76764fe --- /dev/null +++ b/libs/extractor/src/imported_constants/safety_tests.rs @@ -0,0 +1,139 @@ +use super::*; +use rstest::rstest; + +#[rstest] +#[case("__proto__", "false|red")] +#[case("['__proto__']", "true|red")] +fn lexical_copy_when_proto_is_a_setter_does_not_invent_an_own_property( + #[case] key: &str, + #[case] expected: &str, +) { + let allocator = Allocator::default(); + let source = format!( + "import {{css}} from '@devup-ui/react'; function run(){{const props={{{key}:null,color:'red'}};return css({{...props}});}} run();" + ); + let mut parsed = Parser::new(&allocator, &source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + + inline_constants( + &AstBuilder::new(&allocator), + &mut parsed.program, + "proto.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &crate::import_alias_visit::PxMarks::default(), + ); + + parsed + .program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let code = oxc_codegen::Codegen::new().build(&parsed.program).code; + let script = format!( + "const css = props => [Object.hasOwn(props, '__proto__'), props.color].join('|'); {code}" + ); + let mut context = boa_engine::Context::default(); + let actual = context + .eval(boa_engine::Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}: {script}")); + assert_eq!( + actual + .as_string() + .unwrap_or_else(|| panic!("string required: {actual:?}")) + .to_std_string_escaped(), + expected, + "{code}" + ); +} + +#[rstest] +#[case("tone; const tone='red';", None)] +#[case("const tone='red'; tone;", Some("\"red\""))] +fn inline_read_when_the_binding_initializes_on_either_side_of_it_is_safe( + #[case] source: &str, + #[case] expected: Option<&str>, +) { + let allocator = Allocator::default(); + let ast_builder = AstBuilder::new(&allocator); + let parsed = Parser::new(&allocator, source, SourceType::ts()).parse(); + let scoping = SemanticBuilder::new() + .build(&parsed.program) + .semantic + .into_scoping(); + let initialization = initialization::Initialization::new(&parsed.program, &scoping); + let style = StyleSymbols::new(&scoping); + let css_props = CssTakers::new(&parsed.program, &scoping, CssProp::Off, "@devup-ui/react"); + let symbol = scoping + .get_root_binding("tone".into()) + .unwrap_or_else(|| panic!("tone binding")); + let symbols = FxHashMap::from_iter([(symbol, Constant::String("red".to_string()))]); + let inline = Inline { + ast_builder: &ast_builder, + scoping: &scoping, + initialization: &initialization, + symbols: &symbols, + style: &style, + css_props: &css_props, + css_failures: &mut Vec::new(), + unknown: &mut Unknown::default(), + objects: false, + styles: false, + px: false, + position: InlinePosition::Rules, + marks: &crate::import_alias_visit::PxMarks::default(), + class_names: Vec::new(), + }; + let expression = parsed + .program + .body + .iter() + .find_map(|statement| match statement { + Statement::ExpressionStatement(statement) => Some(&statement.expression), + _ => None, + }) + .unwrap_or_else(|| panic!("tone read")); + assert_eq!( + inline + .constant(expression) + .and_then(|value| value.js_literal()) + .as_deref(), + expected + ); +} + +#[rstest] +#[case(true)] +#[case(false)] +fn namespace_object_when_a_nested_member_is_a_function_is_recognized(#[case] function: bool) { + let leaf = if function { + Constant::Function + } else { + Constant::Number(2.0) + }; + let object = Constant::Object(Rc::new(FxHashMap::from_iter([( + "member".to_string(), + leaf, + )]))); + assert_eq!(object.has_function(), function); +} + +#[rstest] +#[case(vec![])] +#[case(vec![Constant::Number(2.0)])] +#[case(vec![Constant::String("2".to_string()), Constant::Number(3.0)])] +#[case(vec![Constant::Number(2.0), Constant::String("3".to_string())])] +fn exact_power_when_operands_are_not_two_numbers_does_not_fold(#[case] arguments: Vec) { + assert!(fold_math("pow", &arguments).is_none()); +} + +#[test] +#[serial_test::serial] +fn nested_math_constant_when_read_by_computed_key_keeps_its_exact_value() { + let output = super::exact_tests::extracted("import {Box} from '@devup-ui/react'; export function a(){const n=Math['PI'];return ;}", "") + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + super::exact_tests::static_values(&output), + vec!["3.141592653589793".to_string()] + ); +} diff --git a/libs/extractor/src/imported_constants/tdz_tests.rs b/libs/extractor/src/imported_constants/tdz_tests.rs new file mode 100644 index 000000000..6834e6eb8 --- /dev/null +++ b/libs/extractor/src/imported_constants/tdz_tests.rs @@ -0,0 +1,171 @@ +use oxc_allocator::Allocator; +use oxc_ast::builder::AstBuilder; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use super::{Inlined, inline_constants}; +use crate::ExtractOption; +use crate::css_prop::CssProp; + +fn inline(code: &str) -> (String, Inlined) { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let result = inline_constants( + &AstBuilder::new(&allocator), + &mut program, + "tdz.tsx", + &ExtractOption::default(), + None, + CssProp::Off, + &crate::import_alias_visit::PxMarks::default(), + ); + (Codegen::new().build(&program).code, result) +} + +#[test] +fn immediate_style_reads_when_lexical_bindings_are_uninitialized() { + for body in [ + "const view = ; const color = 'red';", + "const view = ; let color = 'red';", + "{ const view = ; const color = 'red'; }", + "function render() { const view = ; const color = 'red'; return view; }", + "const render = () => { const view = ; let color = 'red'; return view; };", + "const view = ; class color {}", + "const color = ;", + "const color = Math.max(color, 1); const view = ;", + "const color = later; const view = ; const later = 'red';", + "const view = (() => )(); const color = 'red';", + "const view = (function () { return ; })(); const color = 'red';", + "function render() { const color = later; const later = color; return ; }", + ] { + let source = format!("import {{ Box }} from '@devup-ui/react';\n{body}"); + let (_, result) = inline(&source); + assert_eq!(result.errors.len(), 1, "{source}"); + let (at, message) = &result.errors[0]; + let name = if body.contains("later") { + "later" + } else { + "color" + }; + assert_eq!( + &source[usize::try_from(*at).unwrap_or_else(|error| panic!("try_from at: {error}"))..] + [..name.len()], + name + ); + assert!(message.contains("ReferenceError"), "{message}"); + let located = crate::located_errors("tdz.tsx", &source, &[], result.errors); + assert!(located.starts_with("tdz.tsx:2:"), "{located}"); + } +} + +#[test] +fn deferred_style_reads_when_outer_bindings_initialize_before_invocation() { + for body in [ + "function Render() { return ; } const color = 'red';", + "const Render = () => ; const color = 'red';", + "register(() => ); const color = 'red';", + "function outer() { const Render = () => ; const color = 'red'; return Render; }", + "{ const Render = () => ; const color = 'red'; }", + ] { + let (code, result) = inline(&format!("import {{ Box }} from '@devup-ui/react';\n{body}")); + assert_eq!(result.errors, vec![], "{body}"); + assert!(code.contains("color={\"red\"}"), "{code}"); + } +} + +#[test] +fn var_reads_when_initializers_have_not_run_stay_dynamic() { + let source = "import { Box } from '@devup-ui/react'; const view = ; var color = 'red';"; + let (code, result) = inline(source); + assert_eq!(result.errors, vec![]); + assert!(code.contains("color={color}"), "{code}"); +} + +#[test] +#[serial_test::serial] +fn var_reads_when_rendered_before_assignment_use_css_variables() { + let output = super::exact_tests::extracted("import { Box } from '@devup-ui/react'; const view = ; var color = 'red';", "").unwrap_or_else(|error| panic!("var_reads_when_rendered_before_assignment_use_css_variables: {error}")); + assert!(output.code.contains("--"), "{}", output.code); +} + +#[test] +fn style_reads_when_shadowed_bindings_have_distinct_initialization() { + let source = "import { Box } from '@devup-ui/react'; const color = 'red'; function outer() { const view = ; const color = 'green'; return view; }"; + let (_, result) = inline(source); + assert_eq!(result.errors.len(), 1); + assert_eq!( + &source[usize::try_from(result.errors[0].0) + .unwrap_or_else(|error| panic!("try_from error offset: {error}"))..][..5], + "color" + ); +} + +#[test] +fn nonstyle_reads_when_preinitialization_is_not_a_style_dependency_are_untouched() { + let (code, result) = inline( + "import { Box } from '@devup-ui/react'; observe(color); const color = 'red'; const view = ;", + ); + assert_eq!(result.errors, vec![]); + assert!(code.contains("observe(color)"), "{code}"); + assert!(code.contains("color={\"red\"}"), "{code}"); +} + +#[test] +#[serial_test::serial] +fn class_self_reads_when_static_field_initialization_has_started() { + for source in [ + "import { Box } from '@devup-ui/react'; export class Palette { static tone = 'red'; static view = ; }", + "import { Box } from '@devup-ui/react'; class Palette { static tone = 'red'; static { const view = ; } }", + ] { + let output = super::exact_tests::extracted(source, "").unwrap_or_else(|error| { + panic!("class_self_reads_when_static_field_initialization_has_started: {error}") + }); + assert!(output.code.contains("Palette.tone"), "{}", output.code); + assert!(output.code.contains("className="), "{}", output.code); + } +} + +#[test] +fn class_self_reads_when_method_execution_is_deferred() { + let source = "import { Box } from '@devup-ui/react'; class Palette { view() { return ; } static tone = 'red'; }"; + let (_, result) = inline(source); + assert_eq!(result.errors, vec![]); +} + +#[test] +fn class_self_reads_when_class_binding_is_still_uninitialized() { + for body in [ + "const view = ; class Palette { static tone = 'red'; }", + "class Palette extends () { static tone = 'red'; }", + "class Palette { static tone = 'red'; static [] = 1; }", + ] { + let source = format!("import {{ Box }} from '@devup-ui/react';\n{body}"); + let (_, result) = inline(&source); + assert_eq!(result.errors.len(), 1, "{source}"); + let (at, message) = &result.errors[0]; + assert_eq!( + &source[usize::try_from(*at) + .unwrap_or_else(|error| panic!("try_from class read offset: {error}"))..][..7], + "Palette" + ); + assert!(message.contains("ReferenceError"), "{message}"); + let located = crate::located_errors("tdz.tsx", &source, &[], result.errors); + assert!(located.starts_with("tdz.tsx:2:"), "{located}"); + } +} + +#[test] +fn lexical_reads_when_static_initialization_precedes_their_declaration() { + for body in [ + "class Palette { static view = ; } const tone = 'red';", + "class Palette { static { const view = ; const tone = 'red'; } }", + ] { + let source = format!("import {{ Box }} from '@devup-ui/react'; {body}"); + let (_, result) = inline(&source); + assert_eq!(result.errors.len(), 1, "{source}"); + assert!(result.errors[0].1.contains("ReferenceError")); + } +} diff --git a/libs/extractor/src/jsx_semantics_tests.rs b/libs/extractor/src/jsx_semantics_tests.rs new file mode 100644 index 000000000..99168a6c0 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests.rs @@ -0,0 +1,105 @@ +use super::*; +use crate::extract_style::style_property::StyleProperty; +use css::class_map::reset_class_map; +use css::file_map::reset_file_map; + +mod binding_edges; +mod boundary_classes; +mod boundary_selection; +mod call_diagnostics; +mod call_spread_attachment; +mod calls; +mod capture_edges; +mod capture_preservation; +mod create_element; +mod create_element_attachment; +mod css_prop; +mod errors; +mod evaluation_edges; +mod forwarded; +mod gate_edges; +mod ordered; +mod pass_through; +mod pass_through_edges; +mod pass_through_known; +mod repeated; +mod slots; +mod spread_repair; +mod style_order; +mod styled; +mod type_imports; +mod whole; + +const BOX: &str = "import { Box } from '@devup-ui/react';\n"; +const EMOTION: &str = "/** @jsxImportSource @emotion/react */\n"; +const JSX_RUNTIME: &str = "import { jsx } from 'react/jsx-runtime';\n"; + +fn compile_with(code: &str, option: ExtractOption) -> Result { + reset_class_map(); + reset_file_map(); + css::debug::set_debug(true); + let output = extract("a.tsx", code, option).map_err(|error| error.to_string()); + css::debug::set_debug(false); + output +} + +fn compile(code: &str) -> Result { + compile_with(code, ExtractOption::default()) +} + +fn compile_emotion(code: &str) -> Result { + compile_with( + code, + ExtractOption { + import_aliases: HashMap::from([( + "@emotion/react".to_string(), + ImportAlias::NamedToNamed, + )]), + ..ExtractOption::default() + }, + ) +} + +/// The output of a file that compiles +fn output(source: &str) -> ExtractOutput { + compile(source).unwrap_or_else(|error| panic!("the file does not compile: {error}")) +} + +fn code(source: &str) -> String { + output(source).code +} + +/// The message of a file that does not compile +fn error(source: &str) -> String { + compile(source) + .err() + .unwrap_or_else(|| panic!("the negative fixture compiles: {source}")) +} + +fn static_styles(output: &ExtractOutput) -> Vec<(String, String)> { + let mut found: Vec<_> = output + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(style) => { + Some((style.property().to_string(), style.value().to_string())) + } + _ => None, + }) + .collect(); + found.sort(); + found +} + +/// What the JavaScript `body` returns, run as the build's output runs it +fn run(body: &str) -> String { + let script = format!("(function() {{ {body} }})()"); + let mut context = boa_engine::Context::default(); + let value = context + .eval(boa_engine::Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}: {script}")); + value.to_string(&mut context).map_or_else( + |error| panic!("{error}: {script}"), + |text| text.to_std_string_escaped(), + ) +} diff --git a/libs/extractor/src/jsx_semantics_tests/binding_edges.rs b/libs/extractor/src/jsx_semantics_tests/binding_edges.rs new file mode 100644 index 000000000..5b93ea601 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/binding_edges.rs @@ -0,0 +1,194 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("(Box)")] +#[case("Box as typeof Box")] +#[case("Box satisfies typeof Box")] +#[serial] +fn wrapped_component_bindings_when_create_element_reads_them_compile(#[case] component: &str) { + let source = format!( + "{BOX}import {{createElement}} from 'react'; export const a=()=>createElement({component},{{color:'red'}},'child');" + ); + let actual = evaluate(&source, "a()"); + assert_eq!(actual.element["type"], "div"); + assert_eq!(actual.element["children"], serde_json::json!(["child"])); +} + +#[test] +#[serial] +fn shadowed_undefined_when_used_as_props_is_forwarded() { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=(undefined)=>createElement(Box,undefined,'child');" + ); + let actual = evaluate(&source, "a({id:'kept'})"); + assert_eq!(actual.element["props"]["id"], "kept"); +} + +#[test] +#[serial] +fn void_effect_when_used_as_props_runs_before_children() { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=()=>createElement(Box,void trace.push('props'),(trace.push('children'),'child'));" + ); + let actual = evaluate(&source, "a()"); + assert_eq!(actual.trace, serde_json::json!(["props", "children"])); +} + +#[rstest] +#[case("undefined")] +#[case("void effect()")] +#[case("cond ? 1 : undefined")] +#[case("cond ? void effect() : 2")] +#[serial] +fn uncertain_empty_order_when_shadowed_or_effectful_is_located(#[case] order: &str) { + for element in [ + format!(""), + format!("jsx(Box,{{styleOrder:{order},color:'red'}})"), + ] { + let source = + format!("{BOX}{JSX_RUNTIME}export const a=(undefined,effect,cond)=>{element};"); + let message = error(&source); + assert!(message.starts_with("a.tsx:3:"), "{message}"); + assert!(message.contains(order), "{message}"); + } +} + +#[test] +#[serial] +fn shadowed_globals_when_spreads_copy_props_do_not_capture_generated_names() { + let source = format!( + "{BOX}export const a=(Object,undefined,__devupSpread0,__devupValue0)=> ;" + ); + let actual = evaluate(&source, "a(null,'not-undefined',{color:void 0},'red')"); + assert_eq!( + actual.element["props"]["className"] + .as_str() + .unwrap_or_else(|| panic!("class name: {}", actual.element)) + .split_whitespace() + .count(), + 1 + ); + assert!( + actual.element["props"]["style"] + .as_object() + .unwrap_or_else(|| panic!("expected an authored style object")) + .values() + .any(|value| value == "red") + ); +} + +#[rstest] +#[case("jsx(Box,{id:first(),as:type(),color:'red',...rest},child())")] +#[case("createElement(Box,{id:first(),as:type(),color:'red',...rest},child())")] +#[case("React.createElement(Box,{id:first(),as:type(),color:'red',...rest},child())")] +#[serial] +fn dynamic_as_when_props_are_captured_keeps_type_at_its_source_position(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import React,{{createElement}} from 'react';export const a=(first,type,rest,child)=>{element};" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('id');return 'id';},()=>{trace.push('type');return 'section';},{get color(){trace.push('spread');return 'blue';}},()=>{trace.push('child');return 'child';})", + ); + assert_eq!(actual.element["type"], "section"); + assert_eq!( + actual.trace, + serde_json::json!(["id", "type", "spread", "child"]) + ); +} + +#[test] +#[serial] +fn dynamic_as_when_member_factory_looks_up_a_receiver_keeps_it() { + let source = format!( + "{BOX}import React from 'react';export const a=()=>React.createElement(Box,{{color:(trace.push('color'),'red'),as:(trace.push('type'),'a')}},(trace.push('child'),'child'));" + ); + let actual = evaluate( + &source, + "(()=>{React.marker='receiver';Object.defineProperty(React,'createElement',{get(){trace.push('callee');return function(type,props,...children){return {type,props,children,receiver:this.marker};};}});return a();})()", + ); + assert_eq!(actual.element["receiver"], "receiver"); + assert_eq!( + actual.trace, + serde_json::json!(["callee", "color", "type", "child"]) + ); +} + +#[test] +#[serial] +fn shadowed_undefined_when_a_conditional_style_is_absent_stays_absent() { + let source = + format!("{BOX}export const a=(undefined,active,value)=>;"); + let actual = evaluate(&source, "a('incorrect',false,'red')"); + assert!( + actual.element["props"]["style"] + .as_object() + .is_none_or(serde_json::Map::is_empty), + "{}", + actual.element + ); +} + +#[test] +#[serial] +fn locally_compiled_keyframes_when_capture_precedes_props_keep_static_rules() { + let source = "import {Box,keyframes} from '@devup-ui/react';export function a(props){const spin=keyframes({from:{opacity:0},to:{opacity:1}});return ;}"; + let actual = output(source); + assert!( + static_styles(&actual) + .iter() + .any(|(property, _)| property == "animation-name"), + "{}", + actual.code + ); + assert!(!actual.styles.iter().any(|style|matches!(style,ExtractStyleValue::Dynamic(style) if style.property()=="animation-name")),"{}",actual.code); +} + +#[rstest] +#[case("{take('child')}")] +#[case("createElement(Box,{id:take('id'),as:type,...rest,title:take('title')},take('child'))")] +#[serial] +fn absent_dynamic_as_when_captured_defaults_without_reordering_later_props(#[case] element: &str) { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=(type,rest,take)=>{element};" + ); + let actual = evaluate( + &source, + "a(null,{get x(){trace.push('spread');return 1;}},key=>{trace.push(key);return key;})", + ); + assert_eq!(actual.element["type"], "div"); + assert_eq!( + actual.trace, + serde_json::json!(["id", "spread", "title", "child"]) + ); +} + +#[rstest] +#[case("({lg:'large',sm:'small'})[size]")] +#[case("({lg:'large',sm:'small'} as const)[size]")] +#[serial] +fn wrapped_written_typography_when_captured_keeps_its_known_tokens(#[case] value: &str) { + let source = format!( + "{BOX}export const a=(size,rest)=>;" + ); + let actual = output(&source); + assert!( + actual + .styles + .iter() + .any(|style| matches!(style,ExtractStyleValue::Typography(name) if name=="large")), + "{}", + actual.code + ); + assert!( + actual + .styles + .iter() + .any(|style| matches!(style,ExtractStyleValue::Typography(name) if name=="small")), + "{}", + actual.code + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/boundary_classes.rs b/libs/extractor/src/jsx_semantics_tests/boundary_classes.rs new file mode 100644 index 000000000..7408536f6 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/boundary_classes.rs @@ -0,0 +1,197 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn class_boundary_when_isolated_member_call_runs_keeps_receiver_getter_and_iterator_once() { + let source = format!( + "{BOX}export const a=(receiver,args)=>;" + ); + let actual = evaluate( + &source, + "a({marker:'receiver',get read(){trace.push('get');return function(value){trace.push(this.marker+':'+value);return 'opaque';};}},{[Symbol.iterator](){trace.push('iterator');return ['arg'][Symbol.iterator]();}})", + ); + assert_eq!(actual.element["props"]["className"], "opaque"); + assert_eq!( + actual.trace, + serde_json::json!(["get", "iterator", "receiver:arg"]) + ); + assert!(!code(&source).contains("__devupValue")); +} + +#[rstest] +#[case("{get read(){trace.push('get');throw 'boom';}}", "[]", serde_json::json!(["get"]))] +#[case("{get read(){trace.push('get');return ()=>{trace.push('call');};}}", "{[Symbol.iterator](){trace.push('iterator');throw 'boom';}}", serde_json::json!(["get","iterator"]))] +#[case("{get read(){trace.push('get');return function(){trace.push('call');throw 'boom';};}}", "{[Symbol.iterator](){trace.push('iterator');return [][Symbol.iterator]();}}", serde_json::json!(["get","iterator","call"]))] +#[serial] +fn class_boundary_when_call_parts_throw_preserves_the_original_cutoff( + #[case] receiver: &str, + #[case] args: &str, + #[case] trace: serde_json::Value, +) { + let source = format!( + "{BOX}export const a=(receiver,args)=>;" + ); + let actual = evaluate( + &source, + &format!("(()=>{{try{{return a({receiver},{args});}}catch(error){{return error;}}}})()"), + ); + assert_eq!(actual.element, "boom"); + assert_eq!(actual.trace, trace); +} + +#[rstest] +#[case("", "'id'", "early")] +#[case( + "", + "{className:'override'}", + "override" +)] +#[case( + "", + "'override'", + "override" +)] +#[serial] +fn class_boundary_when_other_attributes_can_interfere_still_captures( + #[case] element: &str, + #[case] later: &str, + #[case] expected: &str, +) { + let source = format!("{BOX}export const a=(read,later)=>{element};"); + let actual = evaluate( + &source, + &format!( + "(()=>{{let value='early';return a(()=>{{trace.push('read');return value;}},()=>{{trace.push('later');value='late';return {later};}});}})()" + ), + ); + assert_eq!(actual.trace, serde_json::json!(["read", "later"])); + assert_eq!(actual.element["props"]["className"], expected); + assert!(code(&source).contains("__devupValue")); +} + +#[test] +#[serial] +fn class_boundary_when_style_order_expands_calls_still_evaluates_once() { + let source = format!( + "{BOX}export const a=(read,active)=>;" + ); + let actual = evaluate(&source, "a(()=>{trace.push('read');return 'opaque';},true)"); + assert_eq!(actual.trace, serde_json::json!(["read"])); + assert_eq!(actual.element["props"]["className"], "opaque"); + assert!(code(&source).contains("__devupValue")); +} + +#[test] +#[serial] +fn class_boundary_when_child_yields_keeps_the_class_before_suspension() { + let source = format!( + "{BOX}export function* a(read){{return {{yield (trace.push('yield'),'pause')}};}}" + ); + let actual = evaluate( + &source, + "(()=>{const iterator=a(()=>{trace.push('read');return 'opaque';});iterator.next();trace.push('resume');return iterator.next('child').value;})()", + ); + assert_eq!(actual.trace, serde_json::json!(["read", "yield", "resume"])); + assert_eq!(actual.element["children"], serde_json::json!(["child"])); + assert!(code(&source).contains("__devupValue")); +} + +#[test] +#[serial] +fn class_boundary_when_only_a_css_value_calls_still_captures_its_repeated_read() { + let source = format!("{BOX}export const a=(read)=>;"); + let actual = evaluate(&source, "a(()=>{trace.push('read');return 2;})"); + assert_eq!(actual.trace, serde_json::json!(["read"])); + assert!(code(&source).contains("__devupValue")); +} + +#[rstest] +#[case("")] +#[case("")] +#[case("{'child'}")] +#[case("")] +#[serial] +fn class_boundary_when_root_or_route_is_not_isolated_remains_captured(#[case] element: &str) { + let source = format!( + "import {{Box,styled}} from '@devup-ui/react';const Card=styled(Box,{{color:'red'}});export const a=(read)=>{element};" + ); + assert!(code(&source).contains("__devupValue")); +} + +#[test] +#[serial] +fn class_boundary_when_outer_text_is_forced_to_capture_compiles_before_later_spread() { + let source = format!( + "{BOX}const base=`p-4`;export const a=(clsx,on,rest)=>;" + ); + let compiled = output(&source); + assert_eq!( + static_styles(&compiled), + vec![ + ("margin".into(), ".5rem".into()), + ("padding".into(), "1rem".into()) + ] + ); + let actual = evaluate( + &source, + "a((...parts)=>{trace.push('class');return parts.filter(Boolean).join(' ');},true,{get className(){trace.push('spread');return 'override';}})", + ); + assert_eq!(actual.trace, serde_json::json!(["class", "spread"])); + assert_eq!(actual.element["props"]["className"], "override"); +} + +#[test] +#[serial] +fn class_boundary_when_parameter_shadows_text_and_id_reads_outer_binding_preserves_both() { + let source = format!( + "{BOX}const base='p-4';export const a=(clsx,rest)=>;export const b=(base,clsx,rest)=>;" + ); + let actual = evaluate( + &source, + "(()=>{const clsx=(value)=>value;return [a(clsx,{}).props,b('shadow',clsx,{}).props];})()", + ); + assert_eq!(actual.element[0]["id"], "p-4"); + assert_eq!(actual.element[0]["className"], "padding-0-1rem--255-a"); + assert_eq!(actual.element[1]["id"], "shadow"); + assert_eq!(actual.element[1]["className"], "shadow"); +} + +#[test] +#[serial] +fn class_boundary_when_call_argument_yields_retains_capture_in_the_generator_scope() { + let source = format!( + "{BOX}export function* a(read){{return ;}}" + ); + let actual = evaluate( + &source, + "(()=>{const iterator=a(value=>{trace.push('call:'+value);return 'opaque';});iterator.next();trace.push('resume');return iterator.next('arg').value;})()", + ); + assert_eq!( + actual.trace, + serde_json::json!(["yield", "resume", "call:arg"]) + ); + assert_eq!(actual.element["props"]["className"], "opaque"); + assert!(code(&source).contains("__devupValue")); +} + +#[test] +#[serial] +fn class_boundary_when_merge_reads_outer_text_before_spread_keeps_only_final_padding() { + let source = format!( + "{BOX}const base='p-4';export const a=(rest)=>;" + ); + let compiled = output(&source); + assert_eq!( + static_styles(&compiled), + vec![("padding".into(), "2rem".into())] + ); + let actual = evaluate( + &source, + "a({get className(){trace.push('spread');return 'override';}})", + ); + assert_eq!(actual.trace, serde_json::json!(["spread"])); + assert_eq!(actual.element["props"]["className"], "override"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/boundary_selection.rs b/libs/extractor/src/jsx_semantics_tests/boundary_selection.rs new file mode 100644 index 000000000..a1ee6adb2 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/boundary_selection.rs @@ -0,0 +1,57 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("0")] +#[case("index")] +#[serial] +fn selection_boundary_when_unselected_branches_run_keeps_eager_source_order(#[case] key: &str) { + let source = format!( + "{BOX}export const a=(index,first,second,later)=>;" + ); + let actual = evaluate( + &source, + "a(0,()=>{trace.push('first');return '4px';},()=>{trace.push('second');return '8px';},()=>{trace.push('later');return 'id';})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["first", "second", "later"]) + ); + assert!( + actual.element["props"]["style"] + .as_object() + .is_some_and(|style| { style.values().any(|value| value == "4px") }) + ); +} + +#[rstest] +#[case("0")] +#[case("index")] +#[serial] +fn selection_boundary_when_an_unselected_branch_throws_prevents_later_attributes( + #[case] key: &str, +) { + let source = format!( + "{BOX}export const a=(index,first,second,later)=>;" + ); + let actual = evaluate( + &source, + "(()=>{try{return a(0,()=>{trace.push('first');return '4px';},()=>{trace.push('second');throw 'boom';},()=>{trace.push('later');return 'id';});}catch(error){return error;}})()", + ); + assert_eq!(actual.element, "boom"); + assert_eq!(actual.trace, serde_json::json!(["first", "second"])); +} + +#[test] +#[serial] +fn selection_boundary_when_selected_array_is_nested_keeps_existing_flat_array_error() { + let source = format!("{BOX}export const a=;"); + let message = error(&source); + assert!( + message.contains("responsive arrays must be flat"), + "{message}" + ); + assert!(message.contains("[1, 2]"), "{message}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/call_diagnostics.rs b/libs/extractor/src/jsx_semantics_tests/call_diagnostics.rs new file mode 100644 index 000000000..445263842 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/call_diagnostics.rs @@ -0,0 +1,137 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +fn source(factory: &str, props: &str) -> String { + let module = match factory { + "jsx" | "jsxs" => "react/jsx-runtime", + "jsxDEV" => "react/jsx-dev-runtime", + "createElement" => "react", + _ => panic!("unknown factory: {factory}"), + }; + format!( + "{BOX}import {{{factory}}} from '{module}';\nexport const a=(rest)=>{factory}(Box,{props});" + ) +} + +#[rstest] +#[serial] +fn factory_styles_when_unreadable_report_the_jsx_error_at_the_value( + #[values("jsx", "jsxs", "jsxDEV", "createElement")] factory: &str, + #[values(false, true)] single_css: bool, +) { + let source = source(factory, "{color: () => 1}"); + let jsx = format!("{BOX}export const a=()=> 1}}/>;"); + let option = || ExtractOption { + single_css, + ..ExtractOption::default() + }; + let expected = compile_with(&jsx, option()) + .err() + .unwrap_or_else(|| panic!("JSX unreadable control must fail")); + let offset = source + .find("() => 1") + .unwrap_or_else(|| panic!("missing value")); + let column = source[..offset] + .rsplit('\n') + .next() + .unwrap_or_default() + .len() + + 1; + let actual = compile_with(&source, option()) + .err() + .unwrap_or_else(|| panic!("factory silently drops its explicit style: {source}")); + let category = expected + .split_once(": ``") + .unwrap_or_else(|| panic!("{expected}")); + assert_eq!(actual, format!("a.tsx:3:{column}: ``{}", category.1)); +} + +#[rstest] +#[serial] +fn factory_styles_when_shadowed_keep_the_winner_and_discarded_effects( + #[values("jsx", "jsxs", "jsxDEV", "createElement")] factory: &str, + #[values(false, true)] single_css: bool, + #[values( + "{color:()=>1,color:'red'}", + "{...{color:()=>1},color:'red'}", + "{color:(trace.push('discarded'),()=>1),color:'red'}" + )] + props: &str, +) { + let source = source(factory, props); + let compiled = compile_with( + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + static_styles(&compiled), + vec![("color".into(), "red".into())] + ); + let script = compiled + .code + .replace("jsxs(", "jsx(") + .replace("jsxDEV(", "jsx("); + let actual = whole::execute(&script, "a({})"); + let expected = if props.contains("trace.push") { + serde_json::json!(["discarded"]) + } else { + serde_json::json!([]) + }; + assert_eq!(actual.trace, expected); +} + +#[rstest] +#[serial] +fn factory_styles_when_only_spread_keys_are_unreadable_pass_them_through( + #[values("jsx", "jsxs", "jsxDEV", "createElement")] factory: &str, + #[values(false, true)] single_css: bool, +) { + let source = source(factory, "{color:'red',...rest}"); + let compiled = compile_with( + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + let script = compiled + .code + .replace("jsxs(", "jsx(") + .replace("jsxDEV(", "jsx("); + let actual = whole::execute( + &script, + "(()=>{const raw=()=>{throw 'called';};return a({color:raw}).props.color===raw;})()", + ); + assert_eq!(actual.element, true); + assert_eq!( + static_styles(&compiled), + vec![("color".into(), "red".into())] + ); +} + +#[rstest] +#[serial] +fn factory_values_when_known_copy_keys_are_not_selectors_keep_d1( + #[values("jsx", "jsxs", "jsxDEV", "createElement")] factory: &str, + #[values(false, true)] single_css: bool, +) { + let source = source(factory, "{p:'4px',...{['__proto__']:null,p:'20px'}}"); + let compiled = compile_with( + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + static_styles(&compiled), + vec![("padding".into(), "20px".into())] + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/call_spread_attachment.rs b/libs/extractor/src/jsx_semantics_tests/call_spread_attachment.rs new file mode 100644 index 000000000..ce8072dda --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/call_spread_attachment.rs @@ -0,0 +1,216 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +fn source(runtime: &str, props: &str) -> String { + let module = match runtime { + "jsx" | "jsxs" => "react/jsx-runtime", + "jsxDEV" => "react/jsx-dev-runtime", + _ => panic!("unknown factory: {runtime}"), + }; + let tail = match runtime { + "jsx" | "jsxs" => ", 'key'", + "jsxDEV" => ", 'key', false, undefined, undefined", + _ => panic!("unknown factory: {runtime}"), + }; + format!( + "{BOX}import {{{runtime} as jsx}} from '{module}';export const a=(first,read)=>jsx(Box,{props}{tail});" + ) +} + +fn parity(call: &str, element: &str, probe: &str) -> whole::Evaluation { + let plain = whole::evaluate( + &format!("{BOX}export const a=(first,read)=>{element};"), + probe, + ); + let actual = whole::evaluate(call, probe); + assert_eq!(actual.trace, plain.trace, "{call}"); + assert_eq!(actual.element["type"], plain.element["type"], "{call}"); + assert_eq!(actual.element["props"], plain.element["props"], "{call}"); + actual +} + +#[rstest] +#[case("jsx")] +#[case("jsxs")] +#[case("jsxDEV")] +#[serial] +fn styles_attach_when_the_original_evidence_object_is_parenthesized(#[case] runtime: &str) { + let actual = whole::evaluate( + &source(runtime, "({color:first(),...read()})"), + "(()=>{const e=a(()=>{trace.push('first');return 'red';},()=>{trace.push('spread');return {color:'green'};});return [e.type,e.props.color,(e.props.className||'').trim().length>0,Object.values(e.props.style||{})];})()", + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert_eq!( + actual.element, + serde_json::json!(["div", "green", true, ["red"]]) + ); +} + +#[rstest] +#[serial] +fn props_match_plain_jsx_when_syntax_wrappers_surround_the_object( + #[values("jsx", "jsxs", "jsxDEV")] runtime: &str, + #[values( + "BODY", + "(BODY)", + "((BODY))", + "(BODY as const)", + "(BODY satisfies object)" + )] + wrapper: &str, +) { + let props = wrapper.replace("BODY", "{color:first(),...read()}"); + let actual = parity( + &source(runtime, &props), + "", + "a(()=>{trace.push('first');return 'red';},()=>{trace.push('spread');return {color:'green',id:'kept',className:'external',style:{opacity:0.5}};})", + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert_eq!(actual.element["props"]["color"], "green"); + assert_eq!(actual.element["props"]["style"]["opacity"], 0.5); + assert!( + actual.element["props"]["style"] + .as_object() + .unwrap_or_else(|| panic!("expected an authored style object")) + .values() + .any(|v| v == "red") + ); +} + +#[rstest] +#[case("{color:'green'}")] +#[case("{}")] +#[case("null")] +#[case("undefined")] +#[case("Object.create({color:'inherited'})")] +#[case("Object.defineProperty({},'color',{value:'hidden'})")] +#[serial] +fn authored_values_when_spreads_copy_own_enumerable_keys_remain_attached( + #[values("jsx", "jsxs", "jsxDEV")] runtime: &str, + #[case] spread: &str, +) { + let actual = parity( + &source(runtime, "({color:first(),...read()})"), + "", + &format!( + "(()=>{{const e=a(()=>{{trace.push('first');return 'red';}},()=>{{trace.push('spread');return {spread};}});return Object.values(e.props.style||{{}});}})()" + ), + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert_eq!(actual.element, serde_json::json!(["red"])); +} + +#[rstest] +#[serial] +fn absent_own_values_when_passed_through_keep_the_authored_style( + #[values("jsx", "jsxs", "jsxDEV")] runtime: &str, + #[values("null", "undefined", "false")] absent: &str, +) { + let actual = parity( + &source(runtime, "({color:first(),...read()})"), + "", + &format!( + "(()=>{{const e=a(()=>{{trace.push('first');return 'red';}},()=>{{trace.push('spread');return {{color:{absent}}};}});return [Object.hasOwn(e.props,'color'),Object.is(e.props.color,{absent}),Object.values(e.props.style||{{}})];}})()" + ), + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert_eq!(actual.element, serde_json::json!([true, true, ["red"]])); +} + +#[rstest] +#[serial] +fn getters_when_passed_through_run_once(#[values("jsx", "jsxs", "jsxDEV")] runtime: &str) { + let actual = parity( + &source(runtime, "({color:first(),...read()})"), + "", + "a(()=>{trace.push('first');return 'red';},()=>{trace.push('spread');return {get color(){trace.push('getter');return 'green';}};})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["first", "spread", "getter"]) + ); + assert_eq!(actual.element["props"]["color"], "green"); +} + +#[rstest] +#[serial] +fn explicit_values_when_written_after_unknown_spreads_attach( + #[values("jsx", "jsxs", "jsxDEV")] runtime: &str, +) { + let actual = parity( + &source(runtime, "({...read(),color:first(),_hover:{bg:'blue'}})"), + "", + "a(()=>{trace.push('first');return 'red';},()=>{trace.push('spread');return {color:'green',id:'kept'};})", + ); + assert_eq!(actual.trace, serde_json::json!(["spread", "first"])); + assert_eq!(actual.element["props"]["color"], "green"); +} + +#[rstest] +#[serial] +fn responsive_values_when_spreads_follow_match_jsx( + #[values("jsx", "jsxs", "jsxDEV")] runtime: &str, + #[values("{}", "{color:'green'}")] spread: &str, +) { + let actual = parity( + &source(runtime, "({color:[first(),null,'blue'],...read()})"), + "", + &format!( + "a(()=>{{trace.push('first');return 'red';}},()=>{{trace.push('spread');return {spread};}})" + ), + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert!( + actual.element["props"]["style"] + .as_object() + .unwrap_or_else(|| panic!("expected an authored style object")) + .values() + .any(|v| v == "red") + ); +} + +#[rstest] +#[serial] +fn conditional_order_when_props_are_wrapped_attaches_both_branches( + #[values("jsx", "jsxs", "jsxDEV")] runtime: &str, + #[values("true", "false")] enabled: &str, +) { + let actual = parity( + &source( + runtime, + "({styleOrder:first.on?2:4,color:first(),...read(),_hover:{bg:'blue'}})", + ), + "", + &format!( + "a(Object.assign(()=>{{trace.push('first');return 'red';}},{{on:{enabled}}}),()=>{{trace.push('spread');return {{color:'green'}};}})" + ), + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert!( + actual.element["props"]["style"] + .as_object() + .unwrap_or_else(|| panic!("expected an authored style object")) + .values() + .any(|v| v == "red") + ); +} + +#[rstest] +#[serial] +fn selectors_when_unknown_spreads_follow_wrapped_props_compile( + #[values("jsx", "jsxs", "jsxDEV")] runtime: &str, +) { + let actual = parity( + &source(runtime, "({_hover:{bg:'blue'},...read()})"), + "", + "a(null,()=>({_hover:{bg:'green'}}))", + ); + assert_eq!(actual.element["props"]["_hover"]["bg"], "green"); + assert!( + actual.element["props"]["className"] + .as_str() + .unwrap_or_else(|| panic!("expected a selector class name")) + .contains("blue") + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/calls.rs b/libs/extractor/src/jsx_semantics_tests/calls.rs new file mode 100644 index 000000000..73b38b17d --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/calls.rs @@ -0,0 +1,146 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; +const CREATE_ELEMENT: &str = "import {createElement} from 'react';\n"; + +#[rstest] +#[case("jsx(Box,{color:'red',...rest})", "color", "red")] +#[case("jsx(Box,{...rest,color:'red'})", "color", "red")] +#[case("jsx(Box,{color:'red',...rest,color:'blue'})", "color", "blue")] +#[case("jsx(Box,{bg:'red',background:'blue',...rest})", "background", "blue")] +#[case("jsx(Box,{...{color:'red'},...rest})", "color", "red")] +#[case("createElement(Box,{color:'red',...rest})", "color", "red")] +#[serial] +fn calls_when_unknown_spreads_interleave_keep_authored_styles( + #[case] element: &str, + #[case] property: &str, + #[case] value: &str, +) { + let source = format!("{BOX}{JSX_RUNTIME}{CREATE_ELEMENT}export const a=(rest)=>{element};"); + assert_eq!( + static_styles(&output(&source)), + vec![(property.into(), value.into())] + ); + assert_eq!( + whole::evaluate(&source, "a({color:'raw',bg:'raw'}).props.color").element, + "raw" + ); +} + +#[rstest] +#[case("jsx(Box,{color:'red',...first,color:'blue',...last})")] +#[case("createElement(Box,{color:'red',...first,color:'blue',...last},'child')")] +#[serial] +fn calls_when_repeated_spreads_own_keys_preserve_dom_precedence(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}{CREATE_ELEMENT}export const a=(first,last)=>{element}; + " + ); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "blue".into())] + ); + for (first, last, expected) in [ + ("{color:'x'}", "{}", "x"), + ("{}", "{ color: undefined }", "undefined"), + ("{}", "{color:'y'}", "y"), + ] { + assert_eq!( + whole::evaluate(&source, &format!("String(a({first},{last}).props.color)")).element, + expected + ); + } + if element.starts_with("createElement") { + assert!(output(&source).code.contains("\"child\"")); + } +} + +#[test] +#[serial] +fn calls_when_runtime_style_is_explicit_ignore_raw_absence() { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(rest,x)=>jsx(Box,{{p:x,...rest}});"); + for rest in ["{ p: undefined }", "{}"] { + assert_eq!( + whole::evaluate(&source, &format!("Object.values(a({rest},5).props.style)")).element, + serde_json::json!([5]) + ); + } +} + +#[test] +#[serial] +fn calls_when_spread_sources_are_effectful_read_them_once() { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(f)=>jsx(Box,{{color:'red',...f()}});"); + let actual = whole::evaluate( + &source, + "a(()=>{trace.push('spread');return {color:'blue'};})", + ); + assert_eq!(actual.trace, serde_json::json!(["spread"])); + assert_eq!(actual.element["props"]["color"], "blue"); +} + +#[test] +#[serial] +fn calls_when_selectors_precede_spreads_keep_the_selector() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(rest)=>jsx(Box,{{_hover:{{bg:'red'}},...rest}});" + ); + assert_eq!( + static_styles(&output(&source)), + vec![("background".into(), "red".into())] + ); + assert_eq!( + whole::evaluate(&source, "a({_hover:{bg:'raw'}}).props._hover.bg").element, + "raw" + ); +} + +#[test] +#[serial] +fn calls_when_dom_props_are_explicit_keep_them() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a=(rest)=>jsx(Box,{{as:'a',id:'x',styleOrder:3,color:'red',...rest}});" + )); + assert!(rendered.contains("jsx(\"a\""), "{rendered}"); + assert!(rendered.contains("id: \"x\""), "{rendered}"); +} + +#[test] +#[serial] +fn calls_when_style_keys_are_not_identifiers_keep_raw_keys() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(rest)=>jsx(Box,{{'background-color':'red',...rest}});" + ); + assert_eq!( + whole::evaluate( + &source, + "a({'background-color':'blue'}).props['background-color']" + ) + .element, + "blue" + ); + assert_eq!( + static_styles(&output(&source)), + vec![("background-color".into(), "red".into())] + ); +} + +#[test] +#[serial] +fn calls_when_props_suspend_keep_suspension_in_the_caller() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a=async(k,f,g)=>jsx(Box,{{color:'red',[k]:await g(),...f()}});" + )); + assert_eq!(rendered.matches("f()").count(), 1, "{rendered}"); + assert!(rendered.contains("await g()"), "{rendered}"); +} + +#[test] +#[serial] +fn create_element_when_props_are_opaque_keeps_native_merging() { + let rendered = code(&format!( + "{CREATE_ELEMENT}{BOX}export const a=(p)=>createElement(Box,p,'x');" + )); + assert!(rendered.contains("...p"), "{rendered}"); + assert!(rendered.contains("?.className"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/capture_edges.rs b/libs/extractor/src/jsx_semantics_tests/capture_edges.rs new file mode 100644 index 000000000..239e58010 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/capture_edges.rs @@ -0,0 +1,70 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("false && load()", false)] +#[case("true && load()", true)] +#[case("null ?? load()", true)] +#[case("false || load()", true)] +#[serial] +fn literal_logical_when_the_right_side_runs_only_if_selected( + #[case] value: &str, + #[case] called: bool, +) { + let source = format!("{BOX}export const a=(load)=>;"); + let actual = evaluate(&source, "a(()=>{trace.push('load');return 'blue';})"); + assert_eq!( + actual.trace, + if called { + serde_json::json!(["load"]) + } else { + serde_json::json!([]) + } + ); +} + +#[test] +#[serial] +fn responsive_iterator_when_captured_runs_before_later_props_and_keeps_holes() { + let source = format!( + "{BOX}export const a=(items,take,later)=>;" + ); + let actual = evaluate( + &source, + "a({[Symbol.iterator](){trace.push('iterator');return [1][Symbol.iterator]();}},()=>{trace.push('take');return '8px';},()=>{trace.push('later');return 'id';})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["iterator", "take", "later"]) + ); +} + +#[test] +#[serial] +fn child_suspension_when_empty_and_constant_children_precede_it_keeps_them() { + let source = format!( + "{BOX}export function* a(rest){{return {{/* empty */}}{{'constant'}}{{yield 'pause'}};}}" + ); + let actual = evaluate( + &source, + "(()=>{const iterator=a({});iterator.next();return iterator.next('child').value;})()", + ); + assert_eq!( + actual.element["children"], + serde_json::json!(["constant", "child"]) + ); +} + +#[test] +#[serial] +fn unrelated_react_call_when_it_receives_a_compile_only_component_is_not_a_factory() { + let message = error(&format!( + "{BOX}import * as React from 'react';export const a=React.cloneElement(Box,{{}});" + )); + assert!( + message.starts_with("a.tsx:2:") && message.contains("Box"), + "{message}" + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/capture_preservation.rs b/libs/extractor/src/jsx_semantics_tests/capture_preservation.rs new file mode 100644 index 000000000..f5155afc2 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/capture_preservation.rs @@ -0,0 +1,70 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn class_templates_when_captured_keep_their_static_tailwind_rules() { + let actual = output(&format!( + "{BOX}export const a=(get,rest)=>;" + )); + let styles = static_styles(&actual); + assert!( + styles + .iter() + .any(|(property, value)| property == "color" && value == "#3B82F6"), + "{styles:?}" + ); + assert!( + styles + .iter() + .any(|(property, value)| property == "padding" && value == "1rem"), + "{styles:?}" + ); + assert_eq!(actual.code.matches("get()").count(), 1); +} + +#[test] +#[serial] +fn computed_literal_selector_keys_when_captured_keep_the_static_rule() { + let actual = output(&format!( + "{BOX}{JSX_RUNTIME}export const a=(rest)=>jsx(Box,{{['_hover']:{{bg:'blue'}},color:'red'}});" + )); + assert!(actual.styles.iter().any(|style|matches!(style,ExtractStyleValue::Static(style) if style.value=="blue"&&style.selector().is_some())),"{}",actual.code); +} + +#[test] +#[serial] +fn computed_literal_selectors_when_unknown_keys_follow_keep_their_rule() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(key)=>jsx(Box,{{['_hover']:{{bg:'blue'}},[key]:'red'}});" + ); + assert!(output(&source).styles.iter().any(|style|matches!(style,ExtractStyleValue::Static(value) if value.value()=="blue" && value.selector().is_some()))); + let actual = whole::evaluate( + &source, + "a({toString(){trace.push('key');return '_hover';}}).props._hover", + ); + assert_eq!(actual.element, "red"); + assert_eq!(actual.trace, serde_json::json!(["key"])); +} + +#[test] +#[serial] +fn named_blue_500_when_captured_keeps_the_main_oklch_palette() { + let actual = output(&format!( + "{BOX}export const a=(get,rest)=>;" + )); + let styles = static_styles(&actual); + assert!( + styles.iter().any(|(property, value)| { + property == "color" && value == "oklch(62.3% .214 259.815)" + }), + "{styles:?}" + ); + assert!( + styles + .iter() + .any(|(property, value)| property == "padding" && value == "1rem"), + "{styles:?}" + ); + assert_eq!(actual.code.matches("get()").count(), 1); +} diff --git a/libs/extractor/src/jsx_semantics_tests/create_element.rs b/libs/extractor/src/jsx_semantics_tests/create_element.rs new file mode 100644 index 000000000..a782fbee8 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/create_element.rs @@ -0,0 +1,145 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn create_element_compiles_by_binding_whatever_it_is_named() { + let rendered = code(&format!( + "import React, {{ createElement as make }} from 'react';\n{BOX}export const named = make(Box, {{ color: 'red' }}, 'child');\nexport const namespace = React.createElement(Box, {{ bg: 'blue' }}, 'a', 'b');" + )); + + assert!( + rendered.contains("make(\"div\", { className: \"color-0-red--255-a\" }, \"child\")"), + "{rendered}" + ); + assert!( + rendered.contains( + "React.createElement(\"div\", { className: \"background-0-blue--255-a\" }, \"a\", \"b\")" + ), + "{rendered}" + ); + assert!(!rendered.contains("Box"), "{rendered}"); +} + +#[test] +#[serial] +fn create_element_through_a_namespace_import_and_require_compiles() { + let rendered = code(&format!( + "import * as R from 'react';\n{BOX}const React = require('react');\nconst {{ createElement: ce }} = require('react');\nexport const a = R.createElement(Box, {{ bg: 'red' }});\nexport const b = React.createElement(Box, {{ bg: 'blue' }});\nexport const c = ce(Box, {{ bg: 'green' }});" + )); + + assert_eq!(rendered.matches("(\"div\"").count(), 3, "{rendered}"); +} + +#[test] +#[serial] +fn create_element_keeps_children_key_and_ref() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (r) => createElement(Box, {{ bg: 'blue', key: 1, ref: r }}, 'a', createElement(Box));" + )); + + assert!(rendered.contains("key: 1"), "{rendered}"); + assert!(rendered.contains("ref: r"), "{rendered}"); + assert!( + rendered.contains("\"a\", createElement(\"div\", {})"), + "{rendered}" + ); +} + +#[test] +#[serial] +fn create_element_without_props_builds_the_element() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = createElement(Box);\nexport const b = createElement(Box, null, 'x');\nexport const c = createElement(Box, undefined);" + )); + + assert_eq!( + rendered.matches("createElement(\"div\", {}").count(), + 3, + "{rendered}" + ); +} + +#[test] +#[serial] +fn create_element_with_props_held_in_a_value_spreads_them() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (p) => createElement(Box, p, 'x');\nexport const b = (f) => createElement(Box, f(), 'y');\nexport const c = createElement(Box, {{ p: 1 }} as {{ p: number }});" + )); + + assert!(rendered.contains("...p"), "{rendered}"); + assert!( + rendered.contains("className: __devupSpread0?.className || \"\""), + "{rendered}" + ); + assert!( + rendered.contains("createElement(\"div\", ((__devupSpread"), + "{rendered}" + ); + assert!( + rendered.contains("className: \"padding-0-4px--255-a\""), + "{rendered}" + ); +} + +#[test] +#[serial] +fn a_shadowed_create_element_is_not_react() { + let message = error(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (createElement) => createElement(Box, {{ color: 'red' }});" + )); + + assert!(message.contains("`Box` is read at runtime"), "{message}"); +} + +#[test] +#[serial] +fn a_shadowed_react_is_not_react() { + let message = error(&format!( + "import React from 'react';\n{BOX}export const a = (React) => React.createElement(Box, {{ color: 'red' }});" + )); + + assert!(message.contains("`Box` is read at runtime"), "{message}"); +} + +#[test] +#[serial] +fn create_element_of_anything_else_is_left_alone() { + let rendered = code(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = createElement('div', {{ color: 'red' }});\nexport const b = createElement(Foo, {{ color: 'red' }});\nexport const c = ;" + )); + + assert!( + rendered.contains("createElement(\"div\", { color: \"red\" })"), + "{rendered}" + ); + assert!( + rendered.contains("createElement(Foo, { color: \"red\" })"), + "{rendered}" + ); +} + +#[test] +#[serial] +fn create_element_given_a_spread_argument_is_read_at_runtime() { + let message = error(&format!( + "import {{ createElement }} from 'react';\n{BOX}export const a = (args) => createElement(Box, ...args);" + )); + + assert!(message.contains("`Box` is read at runtime"), "{message}"); +} + +#[test] +#[serial] +fn create_element_props_that_hold_nothing_build_an_empty_object() { + let rendered = code(&format!( + "import React, {{ createElement, useState }} from 'react';\n{BOX}export const a = createElement(Box, void 0);\nexport const b = React.createElement(Box, null);\nexport const c = useState(0);" + )); + + assert_eq!( + rendered.matches("createElement(\"div\", {})").count(), + 2, + "{rendered}" + ); + assert!(rendered.contains("= useState(0)"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/create_element_attachment.rs b/libs/extractor/src/jsx_semantics_tests/create_element_attachment.rs new file mode 100644 index 000000000..ed28eff9a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/create_element_attachment.rs @@ -0,0 +1,130 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +fn source(props: &str) -> String { + format!( + "{BOX}import {{createElement}} from 'react';export const a=(first,read)=>createElement(Box,{props},'child');" + ) +} + +#[rstest] +#[serial] +fn explicit_styles_when_create_element_props_are_wrapped_remain_independent( + #[values("(BODY)", "((BODY))", "(BODY as const)", "(BODY satisfies object)")] wrapper: &str, + #[values(true, false)] before: bool, + #[values(true, false)] single_css: bool, +) { + // Given an explicit declaration and an unknown spread in either order. + let body = if before { + "{...read(),color:'red',p:1}" + } else { + "{color:'red',p:1,...read()}" + }; + let source = source(&wrapper.replace("BODY", body)); + + // When the complete factory source is extracted and executed. + let output = compile_with( + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("the factory does not compile: {error}")); + let actual = whole::execute( + &output.code, + "(()=>{const raw={};const e=a(null,()=>{trace.push('spread');return {get color(){trace.push('getter');return raw;},p:'blue',id:'kept',className:'external',style:{opacity:0.5}};});return [e.type,e.props.color===raw,e.props.p,e.props.id,e.props.className,e.props.style,e.children];})()", + ); + + // Then authored CSS/classes coexist with raw identity and existing merges. + assert_eq!( + static_styles(&output), + vec![ + ("color".into(), "red".into()), + ("padding".into(), "4px".into()) + ] + ); + assert_eq!(actual.trace, serde_json::json!(["spread", "getter"])); + let suffix = if single_css { "" } else { "-a" }; + assert_eq!( + actual.element, + serde_json::json!(["div", true, "blue", "kept", format!("external color-0-red--255{suffix} padding-0-4px--255{suffix}"), {"opacity":0.5}, ["child"]]) + ); +} + +#[rstest] +#[serial] +fn authored_effects_when_create_element_props_are_wrapped_run_in_source_order( + #[values("(BODY)", "((BODY))", "(BODY as const)", "(BODY satisfies object)")] wrapper: &str, + #[values(true, false)] before: bool, + #[values(true, false)] single_css: bool, +) { + // Given a dynamic explicit style adjacent to an effectful unknown spread. + let body = if before { + "{...read(),color:first()}" + } else { + "{color:first(),...read()}" + }; + let source = source(&wrapper.replace("BODY", body)); + + // When the emitted factory runs with a conflicting raw color. + let output = compile_with( + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("the factory does not compile: {error}")); + let actual = whole::execute( + &output.code, + "(()=>{const e=a(()=>{trace.push('first');return 'red';},()=>{trace.push('spread');return {get color(){trace.push('getter');return 'green';}};});return [e.props.color,(e.props.className||'').trim().length>0,Object.values(e.props.style||{}),e.children];})()", + ); + + // Then the explicit dynamic class/value attaches, with each effect once. + assert_eq!(output.styles.len(), 1); + assert!( + matches!(output.styles.iter().next(), Some(ExtractStyleValue::Dynamic(value)) if value.property() == "color") + ); + assert_eq!( + actual.element, + serde_json::json!(["green", true, ["red"], ["child"]]) + ); + let expected = if before { + vec!["spread", "getter", "first"] + } else { + vec!["first", "spread", "getter"] + }; + assert_eq!(actual.trace, serde_json::json!(expected)); +} + +#[rstest] +#[serial] +fn explicit_styles_when_wrapped_create_element_has_no_spread_compile( + #[values("(BODY)", "((BODY))", "(BODY as const)", "(BODY satisfies object)")] wrapper: &str, + #[values(true, false)] single_css: bool, +) { + // Given the wrapped literal without an unknown spread. + let source = source(&wrapper.replace("BODY", "{color:'red'}")); + + // When the control compiles and runs. + let output = compile_with( + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("the factory does not compile: {error}")); + let actual = whole::execute(&output.code, "a()"); + + // Then absolute red CSS and its class remain attached. + assert_eq!(static_styles(&output), vec![("color".into(), "red".into())]); + let suffix = if single_css { "" } else { "-a" }; + assert_eq!( + actual.element["props"]["className"], + format!("color-0-red--255{suffix}") + ); + assert_eq!(actual.trace, serde_json::json!([])); +} diff --git a/libs/extractor/src/jsx_semantics_tests/css_prop.rs b/libs/extractor/src/jsx_semantics_tests/css_prop.rs new file mode 100644 index 000000000..dd418477a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/css_prop.rs @@ -0,0 +1,70 @@ +use super::*; +use serial_test::serial; + +fn emotion_error(source: &str) -> String { + match compile_emotion(source) { + Err(message) => message, + Ok(compiled) => panic!("the file compiles: {}", compiled.code), + } +} + +fn emotion_output(source: &str) -> ExtractOutput { + compile_emotion(source).unwrap_or_else(|error| panic!("the file does not compile: {error}")) +} + +#[test] +#[serial] +fn the_css_prop_before_a_spread_is_a_located_error() { + let message = emotion_error(&format!( + "{EMOTION}export const a = (rest) =>
;" + )); + + assert!( + message.contains("`css` on `
` cannot use `...rest`"), + "{message}" + ); + assert!(message.starts_with("a.tsx:2:"), "{message}"); + assert!( + message.contains("write the spread before `css`"), + "{message}" + ); +} + +#[test] +#[serial] +fn the_css_prop_after_a_spread_compiles() { + let output = emotion_output(&format!( + "{EMOTION}export const a = (rest) =>
;" + )); + + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); +} + +#[test] +#[serial] +fn the_css_prop_of_a_call_before_a_spread_is_a_located_error() { + let message = emotion_error( + "import { jsx } from '@emotion/react';\nexport const a = (rest) => jsx('div', { css: { color: 'red' }, ...rest });", + ); + + assert!( + message.contains("`css` on `
` cannot use `...rest`"), + "{message}" + ); +} + +#[test] +#[serial] +fn the_css_prop_of_a_call_after_a_spread_compiles() { + let output = emotion_output( + "import { jsx } from '@emotion/react';\nexport const a = (rest) => jsx('div', { ...rest, css: { color: 'red' } });", + ); + + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/errors.rs b/libs/extractor/src/jsx_semantics_tests/errors.rs new file mode 100644 index 000000000..382e76816 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/errors.rs @@ -0,0 +1,82 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn a_selector_object_before_a_spread_keeps_its_rule() { + let compiled = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!( + static_styles(&compiled), + vec![("background".into(), "red".into())] + ); +} + +#[test] +#[serial] +fn a_selector_object_after_a_spread_keeps_its_rule() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(static_styles(&output).len(), 1); +} + +#[test] +#[serial] +fn a_runtime_value_under_a_selector_before_a_spread_keeps_its_value() { + let source = + format!("{BOX}export const a = (rest, x) => ;"); + + assert_eq!( + whole::evaluate( + &source, + "Object.values(a({_hover:'raw'},'red').props.style)" + ) + .element, + serde_json::json!(["red"]) + ); +} + +#[test] +#[serial] +fn explicit_shapes_when_spreads_follow_match_the_spread_absent_control() { + for (attribute, _key) in [ + ("typography=\"body\"", "typography"), + ("bg={['red', 'blue'][index]}", "bg"), + ("positioning={mode}", "positioning"), + ("selectors={{ '& > a': { color: 'red' } }}", "selectors"), + ] { + let compiled = output(&format!( + "{BOX}export const a = (rest, index, mode) => ;" + )); + + let control = output(&format!( + "{BOX}export const a=(rest,index,mode)=>;" + )); + assert_eq!(compiled.styles, control.styles, "{attribute}"); + } +} + +#[test] +#[serial] +fn a_value_the_build_cannot_read_stays_an_error_before_a_spread() { + let message = error(&format!( + "{BOX}export const a = (rest) => 1}} {{...rest}} />;" + )); + + assert!(message.contains("cannot use `() => 1`"), "{message}"); +} + +#[test] +#[serial] +fn a_spread_beside_suspending_props_is_evaluated_once() { + let rendered = code(&format!( + "{BOX}export const a = async (f, load) => ;" + )); + + assert_eq!(rendered.matches("f()").count(), 1, "{rendered}"); + assert!(rendered.contains("await load()"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/evaluation_edges.rs b/libs/extractor/src/jsx_semantics_tests/evaluation_edges.rs new file mode 100644 index 000000000..0cff9f962 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/evaluation_edges.rs @@ -0,0 +1,89 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("active || [2,load()]", "false", true)] +#[case("active || [2,load()]", "'12px'", false)] +#[case("active ?? [2,load()]", "null", true)] +#[case("active ?? [2,load()]", "0", false)] +#[serial] +fn lazy_logical_arrays_when_captured_evaluate_only_the_selected_branch( + #[case] value: &str, + #[case] active: &str, + #[case] loads: bool, +) { + let source = + format!("{BOX}export const a=(active,load,rest)=>;"); + let actual = evaluate( + &source, + &format!( + "a({active},()=>{{trace.push('branch');return '4px';}},{{get id(){{trace.push('spread');return 'id';}}}})" + ), + ); + assert_eq!( + actual.trace, + if loads { + serde_json::json!(["branch", "spread"]) + } else { + serde_json::json!(["spread"]) + } + ); +} + +#[test] +#[serial] +fn literal_spread_with_effectful_keys_when_followed_by_an_unknown_spread_keeps_effects() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(load,rest)=>jsx(Box,{{...{{color:load(),id:load()}},...rest}});" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('load');return 'red';},{color:'blue'})", + ); + assert_eq!(actual.trace, serde_json::json!(["load", "load"])); + assert_eq!(actual.element["props"]["id"], "red"); +} + +#[test] +#[serial] +fn suspending_children_when_wrapped_leave_await_and_yield_in_the_callers_scope() { + for declaration in [ + "async function a(load){return {await load()}{load()};}", + "function* a(load){return {yield load()}{load()};}", + ] { + let rendered = code(&format!("{BOX}export {declaration}")); + assert!(rendered.contains("load()")); + assert!(rendered.contains("__devupValue")); + } +} + +#[test] +#[serial] +fn trailing_children_spread_when_dynamic_as_is_captured_evaluates_the_iterable_once() { + let source = format!( + "{BOX}import {{createElement}} from 'react';export const a=(type,rest,kids)=>createElement(Box,{{as:type(),color:'red',...rest}},...kids());" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('type');return 'section';},{get id(){trace.push('spread');return 'id';}},()=>{trace.push('children');return ['a','b'];})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["type", "spread", "children"]) + ); + assert_eq!(actual.element["children"], serde_json::json!(["a", "b"])); +} + +#[test] +#[serial] +fn duplicate_styles_when_a_throwing_earlier_read_is_discarded_still_throw() { + let source = format!("{BOX}export const a=(bad)=>;"); + assert!(code(&source).contains("bad")); + let actual = evaluate( + &source, + "(()=>{try{return a(missing);}catch(error){return error.name;}})()", + ); + assert_eq!(actual.element, "ReferenceError"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/forwarded.rs b/libs/extractor/src/jsx_semantics_tests/forwarded.rs new file mode 100644 index 000000000..ef65fc767 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/forwarded.rs @@ -0,0 +1,78 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case( + r#""#, + "spread", + 2 +)] +#[case( + r#""#, + "direct", + 1 +)] +#[case( + r#""#, + "spread", + 2 +)] +#[case( + r#""#, + "last", + 2 +)] +#[case( + r#""#, + "spread", + 2 +)] +#[case(r"", "last", 3)] +#[serial] +fn forwarded_props_when_later_values_overwrite_them_match_original_props( + #[case] element: &str, + #[case] class: &str, + #[case] opacity: u8, +) { + let actual = evaluate( + &format!("{BOX}export const a=(rest,more)=>{element};"), + r"a({'className':'spread','style':{'opacity':2}},{'className':'last','style':{'opacity':3}})", + ); + assert_eq!(actual.element["props"]["className"], class); + assert_eq!(actual.element["props"]["style"]["opacity"], opacity); +} + +#[test] +#[serial] +fn shadowed_object_when_multiple_spreads_select_props_does_not_replace_the_intrinsic() { + let actual = evaluate( + &format!( + "{BOX}export const a=(Object,first,last)=>;" + ), + r"(()=>{const e=a(null,{'className':'first','style':{'opacity':2}},{'className':undefined,'style':undefined});return {className:e.props.className,styleUndefined:e.props.style===undefined};})()", + ); + assert!( + actual.element["className"] + .as_str() + .is_some_and(|class| !class.contains("first") && !class.contains("direct")) + ); + assert_eq!(actual.element["styleUndefined"], true); +} + +#[test] +#[serial] +fn ordinary_attributes_when_styles_are_lowered_keep_source_order() { + let source = format!( + "{BOX}export const a=(take)=>{{take('child')}};" + ); + let actual = evaluate( + &source, + r"a(key=>{trace.push(key);return key==='style'?{'opacity':0.5}:key;})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["id", "color", "class", "style", "title", "child"]) + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/gate_edges.rs b/libs/extractor/src/jsx_semantics_tests/gate_edges.rs new file mode 100644 index 000000000..a1671906a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/gate_edges.rs @@ -0,0 +1,203 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("createElement(Box,{...{color:first()},color:'blue'})")] +#[serial] +fn overwritten_known_spreads_when_compiled_still_evaluate_written_values(#[case] element: &str) { + let source = + format!("{BOX}import {{createElement}} from 'react';export const a=(first)=>{element};"); + let actual = evaluate(&source, "a(()=>{trace.push('first');return 'red';})"); + assert_eq!(actual.trace, serde_json::json!(["first"])); +} + +#[test] +#[serial] +fn known_spreads_when_style_keys_precede_dom_keys_keep_their_order() { + let source = + format!("{BOX}export const a=(take)=>;"); + let actual = evaluate(&source, "a(key=>{trace.push(key);return key;})"); + assert_eq!(actual.trace, serde_json::json!(["color", "id"])); +} + +#[test] +#[serial] +fn nested_copy_when_a_later_prop_changes_the_source_keeps_the_original_value() { + let source = format!( + "{BOX}export const a=(rest,later)=>;" + ); + let actual = evaluate( + &source, + "(()=>{let color='red';return a({get color(){trace.push('get');return color;}},()=>{trace.push('later');color='blue';return 'id';});})()", + ); + assert_eq!(actual.trace, serde_json::json!(["get", "later"])); + assert!( + actual.element["props"]["style"] + .as_object() + .is_some_and(|style| style.values().any(|value| value == "red")), + "{}", + actual.element + ); +} + +#[test] +#[serial] +fn literal_selector_when_opaque_is_forwarded_without_extraction() { + let source = format!( + "{BOX}export const a=(rest,key)=>;" + ); + assert_eq!( + whole::evaluate(&source, "a({},'title').props._hover.color").element, + "red" + ); + assert_eq!(output(&source).styles.len(), 0); +} + +#[test] +#[serial] +fn computed_selection_when_a_key_object_coerces_runs_the_coercion_once() { + let source = format!( + "{BOX}export const a=(key,first,second)=>;" + ); + let actual = evaluate( + &source, + "a({toString(){trace.push('key');return 'a';}},'red','blue')", + ); + assert_eq!(actual.trace, serde_json::json!(["key"])); +} + +#[test] +#[serial] +fn template_interpolations_when_later_props_execute_keep_coercion_order() { + let source = format!( + "{BOX}export const a=(value,later)=>;" + ); + let actual = evaluate( + &source, + "a({toString(){trace.push('coerce');return 'external';}},()=>{trace.push('later');return 'other';})", + ); + assert_eq!(actual.trace, serde_json::json!(["coerce", "later"])); +} + +#[rstest] +#[case("2 || load()", "padding", "8px")] +#[case("'$primary' ?? load()", "padding", "$primary")] +#[serial] +fn exact_logical_left_when_right_is_dynamic_keeps_its_static_style( + #[case] value: &str, + #[case] property: &str, + #[case] expected: &str, +) { + let actual = output(&format!( + "{BOX}export const a=(load)=>;" + )); + assert!( + static_styles(&actual).contains(&(property.to_string(), expected.to_string())), + "{}", + actual.code + ); +} + +#[rstest] +#[case("`${color}` + ' !important'", true)] +#[case("`${color}` + ';'", false)] +#[serial] +fn binary_css_suffix_when_captured_is_not_left_in_the_variable( + #[case] value: &str, + #[case] important: bool, +) { + let source = format!("{BOX}export const a=(color)=>;"); + let actual = output(&source); + let runtime = evaluate(&source, "a('red')"); + assert!( + runtime.element["props"]["style"] + .as_object() + .is_some_and(|style| style.values().all(|value| value == "red")), + "{}", + runtime.element + ); + assert!(actual.styles.iter().any(|style|matches!(style,ExtractStyleValue::Dynamic(style) if style.important()==important)),"{}",actual.code); +} + +#[rstest] +#[case( + "async function a(active,load,rest){return ;}", + "await load()" +)] +#[case( + "function* a(active,load,rest){return ;}", + "yield load()" +)] +#[serial] +fn lazy_suspending_props_when_a_sync_wrapper_cannot_move_them_are_located( + #[case] declaration: &str, + #[case] written: &str, +) { + let message = error(&format!("{BOX}export {declaration}")); + assert!( + message.starts_with("a.tsx:2:") + && message.contains(written) + && !message.contains("__devup"), + "{message}" + ); +} + +#[test] +#[serial] +fn nested_iterator_when_later_props_execute_is_expanded_at_its_original_position() { + let source = format!( + "{BOX}export const a=(items,later)=>;" + ); + let actual = evaluate( + &source, + "a({[Symbol.iterator](){trace.push('iterator');return ['red'][Symbol.iterator]();}},()=>{trace.push('later');return 'id';})", + ); + assert_eq!(actual.trace, serde_json::json!(["iterator", "later"])); +} + +#[test] +#[serial] +fn missing_native_choice_when_classes_are_combined_emits_no_undefined_class() { + let source = + format!("{BOX}export const a=(key)=>;"); + let actual = evaluate(&source, "a('missing')"); + assert!( + actual.element["props"]["className"] + .as_str() + .is_some_and(|class| !class.contains("undefined")), + "{}", + actual.element + ); +} + +#[rstest] +#[case("@media")] +#[case("@supports")] +#[serial] +fn hidden_at_rule_when_opaque_is_forwarded_without_extraction(#[case] rule: &str) { + let source = format!( + "{BOX}export const a=(rest,key)=>;" + ); + assert_eq!( + whole::evaluate( + &source, + &format!("a({{}},'title').props['{rule}']['(min-width:500px)'].color") + ) + .element, + "red" + ); + assert_eq!(output(&source).styles.len(), 0); +} + +#[test] +#[serial] +fn object_valued_conditional_template_when_selected_coerces_before_later_interpolations() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(active,later)=>jsx(Box,{{className:`${{active ? {{toString(){{trace.push('coerce');return 'external';}}}} : 'fallback'}} ${{later()}}`}});" + ); + let actual = evaluate(&source, "a(true,()=>{trace.push('later');return 'other';})"); + assert_eq!(actual.trace, serde_json::json!(["coerce", "later"])); +} diff --git a/libs/extractor/src/jsx_semantics_tests/ordered.rs b/libs/extractor/src/jsx_semantics_tests/ordered.rs new file mode 100644 index 000000000..51b93c772 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/ordered.rs @@ -0,0 +1,144 @@ +use super::whole::evaluate; +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("jsx(Box, {color:first(), ...read()})")] +#[case("createElement(Box, {color:first(), ...read()})")] +#[case("")] +#[serial] +fn explicit_effects_when_overwritten_still_run_before_spreads(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react'; export const a = (first, read) => {element};" + ); + let actual = evaluate( + &source, + "a(() => {trace.push('first'); return 'red';}, () => {trace.push('spread'); return {color:'blue'};})", + ); + assert_eq!(actual.trace, serde_json::json!(["first", "spread"])); + assert_eq!(actual.element["props"]["color"], "blue"); +} + +#[rstest] +#[case("")] +#[case("jsx(Box, {color:'red', bg:'gray', ...rest})")] +#[case("createElement(Box, {color:'red', bg:'gray', ...rest})")] +#[serial] +fn getters_when_props_are_forwarded_and_selected_run_once(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react'; export const a = (rest) => {element};" + ); + let actual = evaluate( + &source, + "a({get color(){trace.push('color');return 'blue';},get className(){trace.push('class');return 'user';},get style(){trace.push('style');return {opacity:0.5};}})", + ); + assert_eq!(actual.trace, serde_json::json!(["color", "class", "style"])); + assert_eq!(actual.element["props"]["style"]["opacity"], 0.5); +} + +#[rstest] +#[case("")] +#[case("jsx(Box, {color:x, id:change()})")] +#[case("createElement(Box, {color:x, id:change()})")] +#[serial] +fn earlier_identifier_when_later_props_mutate_it_keeps_its_value(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react'; export function a() {{let x='red';function change(){{x='blue';return 'id';}} return {element};}}" + ); + let actual = evaluate(&source, "a()"); + let style = actual.element["props"]["style"] + .as_object() + .unwrap_or_else(|| panic!("{}", actual.element)); + assert!( + style.values().any(|value| value == "red"), + "{}", + actual.element + ); +} + +#[test] +#[serial] +fn mixed_branches_when_captured_stay_lazy_and_keep_static_spacing() { + let source = format!( + "{BOX}export const a=(active, value, rest) => ;" + ); + let actual = evaluate( + &source, + "a(true,()=>{trace.push('inactive');return '9px';},{get id(){trace.push('spread');return 'id';}})", + ); + assert_eq!(actual.trace, serde_json::json!(["spread"])); + assert!(static_styles(&output(&source)).contains(&("padding".into(), "8px".into()))); +} + +#[test] +#[serial] +fn a_throwing_spread_when_props_precede_it_keeps_the_original_trace() { + let source = format!( + "{BOX}export const a=() => ;" + ); + let actual = evaluate( + &source, + "(()=>{try{return a();}catch(error){return error;}})()", + ); + assert_eq!(actual.element, "boom"); + assert_eq!(actual.trace, serde_json::json!(["id", "getter"])); +} + +#[test] +#[serial] +fn receiver_lookup_when_props_are_captured_precedes_props_and_children() { + let source = format!( + "{BOX}import React from 'react'; export const a=()=>React.createElement(Box,{{color:(trace.push('color'),'red'),...{{get id(){{trace.push('spread');return 'id';}}}}}},(trace.push('child'),'child'));" + ); + let actual = evaluate( + &source, + "(()=>{Object.defineProperty(React,'createElement',{get(){trace.push('callee');return h;}});return a();})()", + ); + assert_eq!( + actual.trace, + serde_json::json!(["callee", "color", "spread", "child"]) + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{p:choose() ? 2 : value(),bg:last()})")] +#[case("createElement(Box,{p:choose() ? 2 : value(),bg:last()})")] +#[serial] +fn conditions_when_classes_and_variables_read_them_run_once(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react';export const a=(choose,value,last)=>{element};" + ); + let actual = evaluate( + &source, + "a(()=>{trace.push('test');return false;},()=>{trace.push('branch');return '9px';},()=>{trace.push('last');return 'red';})", + ); + assert_eq!(actual.trace, serde_json::json!(["test", "branch", "last"])); +} + +#[rstest] +#[case(false)] +#[case(true)] +#[serial] +fn logical_branches_when_captured_keep_short_circuit_evaluation(#[case] active: bool) { + let source = format!( + "{BOX}export const a=(active,load,rest)=>;" + ); + let actual = evaluate( + &source, + &format!( + "a({active},()=>{{trace.push('branch');return '9px';}},{{get id(){{trace.push('spread');return 'id';}}}})" + ), + ); + assert_eq!( + actual.trace, + if active { + serde_json::json!(["branch", "spread"]) + } else { + serde_json::json!(["spread"]) + } + ); + assert!(static_styles(&output(&source)).contains(&("padding".into(), "8px".into()))); +} diff --git a/libs/extractor/src/jsx_semantics_tests/pass_through.rs b/libs/extractor/src/jsx_semantics_tests/pass_through.rs new file mode 100644 index 000000000..a92f653a2 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/pass_through.rs @@ -0,0 +1,144 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("")] +#[case("jsx(Box,{color:'red',...rest})")] +#[case("jsx(Box,{...rest,color:'red'})")] +#[case("createElement(Box,{color:'red',...rest})")] +#[case("createElement(Box,{...rest,color:'red'})")] +#[serial] +fn authored_styles_when_unknown_keys_collide_keep_raw_props(#[case] element: &str) { + let control = output(&format!("{BOX}export const a=()=> ;")); + let source = format!( + "{BOX}{JSX_RUNTIME}import {{createElement}} from 'react';export const a=(rest)=>{element};" + ); + for input in [ + "undefined", + "null", + "false", + "true", + "NaN", + "Infinity", + "-Infinity", + "''", + "0", + "-0", + "2", + "'2'", + "'.5'", + "'1.e2'", + "'0x10'", + "'4\\n'", + "({toString(){throw 'coerced';}})", + "Symbol('raw')", + "1n", + "(()=>1)", + ] { + let actual = whole::evaluate( + &source, + &format!( + "(()=>{{const raw={input};const e=a({{color:raw}});return [e.type,Object.hasOwn(e.props,'color'),Object.is(e.props.color,raw),Object.keys(e.props.style||{{}}),e.props.className];}})()" + ), + ); + assert_eq!(actual.element[0], "div"); + assert_eq!(actual.element[1], true); + assert_eq!(actual.element[2], true); + assert_eq!(actual.element[3], serde_json::json!([])); + assert_eq!( + actual.element[4] + .as_str() + .unwrap_or_else(|| panic!("expected an authored class name")) + .trim(), + "color-0-red--255-a", + "{element}/{input}" + ); + assert_eq!(static_styles(&output(&source)), static_styles(&control)); + assert_eq!(output(&source).styles.len(), control.styles.len()); + } +} + +#[rstest] +#[case("")] +#[case("")] +#[case("")] +#[case("")] +#[case("jsx(Box,{color:'red',...{color:'blue',...rest}})")] +#[case("jsx(Box,{color:'red',[key]:'blue'})")] +#[serial] +fn opaque_objects_when_named_keys_are_visible_stay_whole(#[case] element: &str) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(rest,key)=>{element};"); + let actual = whole::evaluate(&source, "a({},'title')"); + if element.contains("[key]:'blue'") { + assert_eq!(actual.element["props"]["title"], "blue"); + } else { + assert_eq!(actual.element["props"]["color"], "blue"); + } + assert_eq!( + actual.element["props"]["className"] + .as_str() + .unwrap_or_else(|| panic!("expected an authored class name")) + .trim(), + "color-0-red--255-a" + ); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "red".into())] + ); +} + +#[rstest] +#[case("")] +#[case("")] +#[case("jsx(Box,{_hover:{color:'red'},...rest})")] +#[serial] +fn selectors_when_unknown_spreads_follow_compile_independently(#[case] element: &str) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(rest)=>{element};"); + let actual = whole::evaluate( + &source, + "(()=>{const raw={color:'blue'};const e=a({_hover:raw});return [e.props._hover===raw,e.props.className];})()", + ); + assert_eq!(actual.element[0], true); + assert_eq!( + actual.element[1] + .as_str() + .unwrap_or_else(|| panic!("expected a selector class name")) + .trim(), + "color-0-red-_a__c_hover-255-a" + ); +} + +#[rstest] +#[case("jsx", "react/jsx-runtime")] +#[case("jsxs", "react/jsx-runtime")] +#[case("jsxDEV", "react/jsx-dev-runtime")] +#[serial] +fn wrapped_factory_props_when_no_unknown_spread_exists_attach_authored_styles( + #[case] factory: &str, + #[case] module: &str, +) { + for wrapper in [ + "BODY", + "(BODY)", + "((BODY))", + "(BODY as const)", + "(BODY satisfies object)", + ] { + let props = wrapper.replace("BODY", "{color:first()}"); + let source = format!( + "{BOX}import {{{factory} as jsx}} from '{module}';export const a=(first)=>jsx(Box,{props});" + ); + let actual = whole::evaluate( + &source, + "(()=>{const e=a(()=>{trace.push('first');return 'red';});return [e.type,(e.props.className||'').length>0,Object.values(e.props.style||{})];})()", + ); + assert_eq!(actual.trace, serde_json::json!(["first"])); + assert_eq!( + actual.element, + serde_json::json!(["div", true, ["red"]]), + "{factory}/{wrapper}" + ); + } +} diff --git a/libs/extractor/src/jsx_semantics_tests/pass_through_edges.rs b/libs/extractor/src/jsx_semantics_tests/pass_through_edges.rs new file mode 100644 index 000000000..e4be0df64 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/pass_through_edges.rs @@ -0,0 +1,96 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[test] +#[serial] +fn computed_proto_when_local_const_is_only_copied_keeps_known_d1() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=()=>{{const first={{['__proto__']:null,p:'20px'}};return jsx(Box,{{p:'4px',...first}});}};" + ); + let compiled = output(&source); + assert_eq!( + static_styles(&compiled), + vec![("padding".into(), "20px".into())] + ); +} + +#[test] +#[serial] +fn computed_proto_when_known_in_jsx_keeps_the_literal_selector_diagnostic() { + let direct = + format!("{BOX}export const a=()=> ;"); + let copied = format!( + "{BOX}export const a=()=>{{const first={{['__proto__']:null,p:'20px'}};return ;}};" + ); + for source in [direct, copied] { + let message = error(&source); + assert!(message.contains("cannot use `__proto__`"), "{message}"); + assert!(message.contains(crate::utils::SELECTOR_NAME), "{message}"); + } +} + +#[rstest] +#[case("jsx(Box,{color:()=>1,color:'red'})")] +#[case("jsx(Box,{...{color:()=>1},color:'red'})")] +#[case("jsx(Box,{color:(trace.push('discarded'),()=>1),color:'red'})")] +#[serial] +fn shadowed_factory_values_when_unreadable_keep_the_last_known_style(#[case] element: &str) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=()=>{element};"); + let compiled = output(&source); + assert_eq!( + static_styles(&compiled), + vec![("color".into(), "red".into())] + ); + let actual = whole::execute(&compiled.code, "a()"); + let expected = if element.contains("trace.push") { + serde_json::json!(["discarded"]) + } else { + serde_json::json!([]) + }; + assert_eq!(actual.trace, expected); +} + +#[test] +#[serial] +fn computed_proto_when_copied_into_ordinary_objects_stays_an_own_key() { + let source = format!( + "{BOX}export const a=()=>{{const first={{['__proto__']:null,p:'20px'}};const copy={{...first}};return ;}};" + ); + assert_eq!(whole::evaluate(&source, "a().props.title").element, true); +} + +#[rstest] +#[case("
")] +#[case("jsx('div',{css:{color:'red'},...{...{title:'x'}}})")] +#[case("
")] +#[case("jsx('div',{css:{color:'red'},...rest})")] +#[serial] +fn native_emotion_when_spreads_are_not_flat_keeps_its_existing_refusal(#[case] element: &str) { + let source = format!("{EMOTION}{JSX_RUNTIME}export const a=(rest)=>{element};"); + let message = compile_emotion(&source) + .err() + .unwrap_or_else(|| panic!("native admission must stay unchanged")); + assert!(message.contains("a.tsx:3:"), "{message}"); + assert!( + message.contains("a spread after the `css` prop"), + "{message}" + ); +} + +#[rstest] +#[case("
")] +#[case("jsx('div',{css:{color:'red'},...{title:'x'}})")] +#[serial] +fn native_emotion_when_spreads_are_flat_keeps_its_known_style(#[case] element: &str) { + let source = format!("{EMOTION}{JSX_RUNTIME}export const a=()=>{element};"); + let compiled = compile_emotion(&source).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + static_styles(&compiled), + vec![("color".into(), "red".into())] + ); + assert_eq!( + whole::execute(&compiled.code, "a().props.title").element, + "x" + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/pass_through_known.rs b/libs/extractor/src/jsx_semantics_tests/pass_through_known.rs new file mode 100644 index 000000000..1962cb2d5 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/pass_through_known.rs @@ -0,0 +1,174 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("jsx(Box,{px:1,...rest,pl:2,...{...{bg:'red'},background:'blue'}})")] +#[serial] +fn known_composition_when_nested_keeps_aliases_and_partial_targets(#[case] element: &str) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(rest)=>{element};"); + let actual = whole::evaluate(&source, "a({px:9,bg:'green',id:'kept'})"); + assert_eq!(actual.element["props"]["px"], 9); + assert_eq!(actual.element["props"]["bg"], "green"); + assert_eq!(actual.element["props"]["id"], "kept"); + assert_eq!( + static_styles(&output(&source)), + vec![ + ("background".into(), "blue".into()), + ("padding-left".into(), "8px".into()), + ("padding-right".into(), "4px".into()) + ] + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{color:first(),...{color:second(),...{color:'blue'}},...read()})")] +#[serial] +fn known_duplicates_when_discarded_keep_original_effects(#[case] element: &str) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(first,second,read)=>{element};"); + let actual = whole::evaluate( + &source, + "a(()=>{trace.push('first');return 'red';},()=>{trace.push('second');return 'green';},()=>{trace.push('spread');return {color:'raw'};})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["first", "second", "spread"]) + ); + assert_eq!(actual.element["props"]["color"], "raw"); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "blue".into())] + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{typography:'body',positioning:'top',...rest})")] +#[serial] +fn explicit_shapes_when_spreads_follow_keep_ordinary_rules(#[case] element: &str) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(rest)=>{element};"); + let actual = whole::evaluate(&source, "a({typography:'raw',positioning:'raw'})"); + assert_eq!(actual.element["props"]["typography"], "raw"); + assert_eq!( + static_styles(&output(&source)), + vec![("top".into(), "0".into())] + ); + assert!(output(&source).code.contains("typo-body")); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{css:{color:'red'},...rest})")] +#[serial] +fn devup_css_when_unknown_data_follows_compiles_without_replacement(#[case] element: &str) { + let source = format!("{EMOTION}{BOX}{JSX_RUNTIME}export const a=(rest)=>{element};"); + let compiled = compile_emotion(&source).unwrap_or_else(|error| panic!("{error}")); + assert_eq!( + static_styles(&compiled), + vec![("color".into(), "red".into())] + ); + assert!(compiled.code.contains("...")); + let actual = whole::execute( + &compiled.code, + "(()=>{const raw={color:'blue'};const e=a({css:raw});return [e.props.css===raw,e.props.className];})()", + ); + assert_eq!(actual.element[0], true); + assert!( + actual.element[1] + .as_str() + .unwrap_or_else(|| panic!("expected an authored class name")) + .contains("red") + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{p:'4px',...first,...rest})")] +#[serial] +fn local_known_objects_when_only_copied_compose_without_runtime_evaluation(#[case] element: &str) { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(rest)=>{{const first={{p:'20px'}};return {element};}};" + ); + assert_eq!( + static_styles(&output(&source)), + vec![("padding".into(), "20px".into())] + ); + assert_eq!( + whole::evaluate(&source, "a({p:'raw'}).props.p").element, + "raw" + ); +} + +#[rstest] +#[case("first.p='mutated';")] +#[case("const alias=first;alias.p='mutated';")] +#[serial] +fn local_objects_when_mutated_or_aliased_remain_opaque(#[case] mutation: &str) { + let source = format!( + "{BOX}export const a=()=>{{const first={{p:'20px'}};{mutation}return ;}};" + ); + assert_eq!( + static_styles(&output(&source)), + vec![("padding".into(), "4px".into())] + ); + assert_eq!(whole::evaluate(&source, "a().props.p").element, "mutated"); +} + +#[test] +#[serial] +fn raw_values_when_unit_policies_differ_keep_identity_without_coercion() { + let source = format!( + "{BOX}{JSX_RUNTIME}export const a=(rest)=>jsx(Box,{{p:1,animationDuration:1,opacity:0.3,'--custom':'authored',...rest}});" + ); + for input in [ + "undefined", + "null", + "false", + "true", + "NaN", + "Infinity", + "-Infinity", + "''", + "0", + "-0", + "Number.MIN_VALUE", + "Number.MAX_VALUE", + "'02'", + "1n", + "Symbol('s')", + "({[Symbol.toPrimitive](){throw 'coerced';}})", + "(()=>0)", + ] { + let actual = whole::evaluate( + &source, + &format!( + "(()=>{{const raw={input};const e=a({{p:raw,animationDuration:raw,opacity:raw,'--custom':raw}});return ['p','animationDuration','opacity','--custom'].every(key=>Object.hasOwn(e.props,key)&&Object.is(e.props[key],raw));}})()" + ), + ); + assert_eq!(actual.element, true, "{input}"); + } + assert_eq!( + static_styles(&output(&source)), + vec![ + ("--custom".into(), "authored".into()), + ("animation-duration".into(), "1ms".into()), + ("opacity".into(), ".3".into()), + ("padding".into(), "4px".into()) + ] + ); +} + +#[test] +#[serial] +fn iterators_when_local_objects_are_spread_keep_the_original_receiver() { + let source = format!( + "{BOX}export const a=()=>{{const first={{p:0}};return ;}};" + ); + let actual = whole::evaluate( + &source, + "(()=>{Object.prototype[Symbol.iterator]=function*(){this.p++;yield this.p;};return a().props.title;})()", + ); + assert_eq!(actual.element, "1,2"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/repeated.rs b/libs/extractor/src/jsx_semantics_tests/repeated.rs new file mode 100644 index 000000000..fed6bebcc --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/repeated.rs @@ -0,0 +1,137 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("{color:'blue'}", "{}", "blue")] +#[case("{color:'blue'}", "{color:'green'}", "green")] +#[case("{}", "{color:'green'}", "green")] +#[case("{}", "{}", "missing")] +#[case("{ p: 2 }", "{}", "missing")] +#[case("{}", "{ p: 2 }", "missing")] +#[serial] +fn repeated_spreads_when_keys_differ_keep_dom_precedence( + #[case] first: &str, + #[case] last: &str, + #[case] color: &str, +) { + let source = + format!("{BOX}export const a=(a,b)=>;"); + let actual = whole::evaluate( + &source, + &format!("a({first},{last}).props.color??'missing'"), + ); + assert_eq!(actual.element, color); + assert_eq!( + static_styles(&output(&source)), + vec![ + ("color".into(), "red".into()), + ("padding".into(), "4px".into()) + ] + ); +} + +#[rstest] +#[case("{color:undefined}", "undefined")] +#[case("{color:null}", "null")] +#[case("{}", "blue")] +#[serial] +fn own_absence_when_written_later_replaces_only_dom_data( + #[case] last: &str, + #[case] expected: &str, +) { + let source = + format!("{BOX}export const a=(first,last)=>;"); + assert_eq!( + whole::evaluate( + &source, + &format!("String(a({{color:'blue'}},{last}).props.color)") + ) + .element, + expected + ); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "red".into())] + ); +} + +#[rstest] +#[case("", "color", "blue")] +#[case("", "background", "blue")] +#[case("", "color", "red")] +#[case("", "color", "blue")] +#[serial] +fn known_writes_when_spreads_interleave_keep_last_authored_value( + #[case] element: &str, + #[case] property: &str, + #[case] value: &str, +) { + let source = format!("{BOX}export const a=(rest)=>{element};"); + assert_eq!( + static_styles(&output(&source)), + vec![(property.into(), value.into())] + ); + assert_eq!( + whole::evaluate(&source, "a({color:'raw'}).props.color").element, + "raw" + ); +} + +#[test] +#[serial] +fn known_literal_when_unknown_spread_follows_keeps_its_styles() { + let source = + format!("{BOX}export const a=(rest)=>;"); + assert_eq!( + static_styles(&output(&source)), + vec![ + ("color".into(), "red".into()), + ("padding".into(), "4px".into()) + ] + ); + let actual = whole::evaluate(&source, "a({color:'blue',margin:1}).props"); + assert_eq!(actual.element["color"], "blue"); + assert_eq!(actual.element["margin"], 1); +} + +#[test] +#[serial] +fn nested_opaque_spreads_when_followed_by_another_keep_dom_winners() { + let source = format!("{BOX}export const a=(a,b)=>;"); + assert_eq!( + whole::evaluate( + &source, + "[a({color:'x'},{}).props.color,a({color:'x'},{color:'y'}).props.color]" + ) + .element, + serde_json::json!(["x", "y"]) + ); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "red".into())] + ); +} + +#[test] +#[serial] +fn known_literals_when_styles_are_removed_keep_dom_props() { + let rendered = code(&format!( + "{BOX}export const a=(rest)=>;" + )); + assert!(rendered.contains("id: \"x\""), "{rendered}"); + assert!(!rendered.contains("color: \"red\""), "{rendered}"); +} + +#[test] +#[serial] +fn authored_aliases_when_one_target_is_shadowed_keep_the_other() { + let source = format!("{BOX}export const a=(rest)=>;"); + assert_eq!( + static_styles(&output(&source)), + vec![ + ("margin-left".into(), "8px".into()), + ("margin-right".into(), "4px".into()) + ] + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/slots.rs b/libs/extractor/src/jsx_semantics_tests/slots.rs new file mode 100644 index 000000000..d010b3b82 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/slots.rs @@ -0,0 +1,222 @@ +use super::*; +use rstest::rstest; +use serial_test::serial; + +#[rstest] +#[case("")] +#[case("")] +#[serial] +fn explicit_styles_when_spread_order_changes_remain_static(#[case] element: &str) { + let source = format!("{BOX}export const a=(rest)=>{element};"); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "red".into())] + ); + assert_eq!( + whole::evaluate(&source, "a({color:'blue'}).props.color").element, + "blue" + ); +} + +#[rstest] +#[case("{color:'blue'}", "\"blue\"")] +#[case("{color:3}", "3")] +#[case("{color:null}", "null")] +#[case("{}", "undefined")] +#[case("null", "undefined")] +#[case("{color:undefined}", "undefined")] +#[case("Object.create({color:'inherited'})", "undefined")] +#[case("Object.defineProperty({},'color',{value:'hidden'})", "undefined")] +#[serial] +fn raw_props_when_copied_keep_native_values(#[case] rest: &str, #[case] expected: &str) { + let source = format!("{BOX}export const a=(rest)=>;"); + let actual = whole::evaluate( + &source, + &format!( + "(()=>{{const e=a({rest});return [JSON.stringify(e.props.color)??'undefined',e.props.className.trim()];}})()" + ), + ); + assert_eq!( + actual.element, + serde_json::json!([expected, "color-0-red--255-a"]) + ); +} + +#[test] +#[serial] +fn getters_when_spread_props_are_copied_run_once() { + let source = format!("{BOX}export const a=(rest)=>;"); + let actual = whole::evaluate( + &source, + "(()=>{let reads=0;const rest=Object.defineProperty({},'color',{enumerable:true,get(){reads++;return 'x';}});const e=a(rest);return [reads,e.props.color];})()", + ); + assert_eq!(actual.element, serde_json::json!([1, "x"])); +} + +#[test] +#[serial] +fn keys_only_the_spread_gives_stay_untouched() { + let source = format!( + "{BOX}export const a=(rest)=>;" + ); + let actual = whole::evaluate(&source, "a({color:'blue',t:'title'}).props"); + assert_eq!(actual.element["color"], "blue"); + assert_eq!(actual.element["id"], "x"); + assert_eq!(actual.element["title"], "title"); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "red".into())] + ); +} + +#[test] +#[serial] +fn responsive_values_when_spreads_follow_keep_authored_breakpoints() { + let source = format!( + "{BOX}export const a=(rest)=>;" + ); + let mut found: Vec<_> = output(&source) + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(value) => Some((value.level(), value.value().to_string())), + _ => None, + }) + .collect(); + found.sort(); + assert_eq!( + found, + vec![ + (0, "red".into()), + (1, "blue".into()), + (3, "$primary".into()) + ] + ); + assert_eq!( + whole::evaluate(&source, "a({bg:'raw'}).props.bg").element, + "raw" + ); +} + +#[test] +#[serial] +fn distinct_authored_values_when_spreads_follow_remain_distinct_classes() { + let source = format!( + "{BOX}export const a=(rest)=><>;" + ); + let classes: std::collections::BTreeSet<_> = output(&source) + .styles + .iter() + .filter_map(|style| match style.extract(None) { + Some(StyleProperty::ClassName(class_name)) => Some(class_name), + _ => None, + }) + .collect(); + assert_eq!(classes.len(), 2); +} + +#[rstest] +#[case("{}")] +#[case("{ p: 3 }")] +#[case("{ p: undefined }")] +#[case("{ p: null }")] +#[serial] +fn explicit_runtime_values_when_spread_values_differ_remain_authored(#[case] rest: &str) { + let source = format!("{BOX}export const a=(rest,x)=>;"); + assert_eq!( + whole::evaluate(&source, &format!("Object.values(a({rest},5).props.style)")).element, + serde_json::json!([5]) + ); +} + +#[test] +#[serial] +fn conditional_styles_when_spreads_follow_keep_both_branches() { + let source = + format!("{BOX}export const a=(rest,on)=>;"); + assert_eq!( + static_styles(&output(&source)), + vec![ + ("color".into(), "blue".into()), + ("color".into(), "red".into()) + ] + ); + for (on, color) in [(true, "red"), (false, "blue")] { + assert!( + whole::evaluate( + &source, + &format!("a({{color:'green'}},{on}).props.className") + ) + .element + .as_str() + .unwrap_or_else(|| panic!("expected an authored class name")) + .contains(color) + ); + } +} + +#[test] +#[serial] +fn style_order_when_spreads_follow_remains_authored() { + let source = + format!("{BOX}export const a=(rest)=>;"); + assert!(output(&source).styles.iter().all( + |style| matches!(style,ExtractStyleValue::Static(value) if value.style_order()==Some(4)) + )); +} + +#[test] +#[serial] +fn spread_sources_when_selected_or_called_are_read_once() { + let source = format!("{BOX}export const a=(f)=>;"); + let actual = whole::evaluate( + &source, + "a(()=>{trace.push('spread');return {color:'blue'};})", + ); + assert_eq!(actual.trace, serde_json::json!(["spread"])); + assert_eq!(actual.element["props"]["color"], "blue"); + let source = format!("{BOX}export const a=(p)=>;"); + assert_eq!( + whole::evaluate(&source, "a({on:null,b:{color:'blue'}}).props.color").element, + "blue" + ); + assert_eq!(output(&source).code.matches("p.on ?? p.b").count(), 1); +} + +#[test] +#[serial] +fn opaque_literals_when_keys_are_nested_or_computed_forward_them() { + let source = format!( + "{BOX}export const a=(rest)=>;export const b=(k)=>;" + ); + assert_eq!( + whole::evaluate( + &source, + "[a({color:'blue'}).props.color,b('color').props.color]" + ) + .element, + serde_json::json!(["blue", 1]) + ); + assert_eq!( + static_styles(&output(&source)), + vec![("color".into(), "red".into())] + ); +} + +#[test] +#[serial] +fn non_identifier_keys_when_forwarded_stay_raw() { + let source = format!("{BOX}export const a=(rest)=>;"); + assert_eq!( + whole::evaluate( + &source, + "a({'background-color':'blue'}).props['background-color']" + ) + .element, + "blue" + ); + assert_eq!( + static_styles(&output(&source)), + vec![("background-color".into(), "red".into())] + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/spread_boundary.rs b/libs/extractor/src/jsx_semantics_tests/spread_boundary.rs new file mode 100644 index 000000000..f956eb2e3 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/spread_boundary.rs @@ -0,0 +1,174 @@ +use super::*; + +#[rstest] +#[case("[null,1]", [None, Some("4px"), Some("4px"), Some("4px")], [None, Some("4px"), Some("4px"), Some("4px")])] +#[case("on ? [null,1] : [2,null,3]", [None, Some("4px"), Some("4px"), Some("4px")], [Some("8px"), Some("8px"), Some("12px"), Some("12px")])] +#[case("on ? (first.enabled ? null : null) : '4px'", [None; 4], [Some("4px"); 4])] +#[case("on ? (first.enabled ? null : null) : [null,value()]", [None; 4], [None, Some("5px"), Some("5px"), Some("5px")])] +#[case("on ? [value(),null,2] : [null,'12px']", [Some("5px"), Some("5px"), Some("8px"), Some("8px")], [None, Some("12px"), Some("12px"), Some("12px")])] +#[serial] +fn authored_coverage_when_originals_have_holes_keeps_them( + #[case] original: &str, + #[case] yes: [Option<&str>; 4], + #[case] no: [Option<&str>; 4], +) { + let source = format!( + "{BOX}export const a=(rest,on)=>{{const first={{enabled:true}};const value=()=> '5px';return ;}};" + ); + for (on, missing) in [(true, yes), (false, no)] { + for input in [ + "{}", + "{ p: 2 }", + "{ p: 0 }", + "{p:'12px'}", + "{ p: undefined }", + "{ p: null }", + ] { + let actual = selected(&source, &format!("a({input},{on})")); + assert_eq!( + actual, + serde_json::json!(missing), + "{original}/{on}/{input}" + ); + } + } +} + +#[test] +#[serial] +fn raw_variable_when_unresolved_cannot_replace_authored_css() { + let source = format!("{BOX}export const a=(rest)=>;"); + let compiled = output(&source); + let element = whole::evaluate(&source, "a({p:'var(--unresolved-proof)'})").element; + assert_eq!(element["props"]["p"], "var(--unresolved-proof)"); + assert_eq!( + static_styles(&compiled), + vec![("padding".into(), "4px".into())] + ); + assert_eq!( + super::selected(&source, "a({p:'var(--unresolved-proof)'})"), + serde_json::json!(vec!["4px"; 4]) + ); + assert_eq!( + super::selected(&source, "a({})"), + serde_json::json!(vec!["4px"; 4]) + ); +} + +#[rstest] +#[case("")] +#[case("jsx(Box,{p:value(),...first,...last,id:tick('id')})")] +#[case("jsx(Box,{as:tag,p:value(),...first,...last,id:tick('id')})")] +#[serial] +fn spread_boundary_when_frontdoors_capture_effects_both_arms_keep_the_authored_order( + #[case] element: &str, +) { + let source = format!( + "{BOX}import {{jsx}} from 'react/jsx-runtime';export const a=(own)=>{{const tick=x=>(trace.push(x),x);const tag='section';const value=()=> (tick('value'),'5px');const first={{get p(){{tick('first');return 2;}}}};const last=own?{{get p(){{tick('last');return undefined;}}}}:{{get title(){{tick('last');return 'x';}}}};return {element};}};" + ); + for own in [true, false] { + let actual = whole::evaluate(&source, &format!("a({own})")); + assert_eq!( + actual.trace, + serde_json::json!(["value", "first", "last", "id"]) + ); + let expected = [Some("5px"); 4]; + assert_eq!( + selected(&source, &format!("a({own})")), + serde_json::json!(expected) + ); + } +} + +#[test] +#[serial] +fn spread_boundary_when_nested_elements_reuse_user_names_keeps_frames_isolated() { + let source = [BOX, "export const a=(rest)=>{const __devupOwn0=17,__devupRaw0=19,__devupPresent0=23,__devupConverted0=29;const h0=31,r0=37,p0=41,v0=43;return {[__devupOwn0,__devupRaw0,__devupPresent0,__devupConverted0]}{[h0,r0,p0,v0]};};"].concat(); + let actual = whole::evaluate(&source, "a({})"); + assert_eq!(actual.trace, serde_json::json!(["inner"])); + assert_eq!(actual.element["children"][0]["props"]["px"], 3); + assert_eq!( + static_styles(&output(&source)) + .iter() + .filter(|(property, value)| property.starts_with("padding-") && value == "8px") + .count(), + 2 + ); + assert_eq!( + actual.element["children"][1], + serde_json::json!([17, 19, 23, 29]) + ); + assert_eq!( + actual.element["children"][2], + serde_json::json!([31, 37, 41, 43]) + ); +} + +#[rstest] +#[case( + "" +)] +#[case( + "jsx(Box,{p:(trace.push('test'),on)?[null,value('left')]:[value('right'),null,3],...rest,id:(trace.push('id'),'x')})" +)] +#[serial] +fn spread_boundary_when_missing_or_supplied_keeps_original_conditional_effects( + #[case] element: &str, +) { + let source = format!( + "{BOX}import {{jsx}} from 'react/jsx-runtime';export const a=(own,on)=>{{const value=x=>(trace.push(x),x==='left'?'5px':'6px');const rest=own?{{get p(){{trace.push('spread');return 2;}}}}:{{get title(){{trace.push('spread');return 'title';}}}};return {element};}};" + ); + for own in [true, false] { + for on in [true, false] { + let actual = whole::evaluate(&source, &format!("a({own},{on})")); + assert_eq!( + actual.trace, + serde_json::json!(["test", if on { "left" } else { "right" }, "spread", "id"]) + ); + let expected = if on { + [None, Some("5px"), Some("5px"), Some("5px")] + } else { + [Some("6px"), Some("6px"), Some("12px"), Some("12px")] + }; + assert_eq!( + selected(&source, &format!("a({own},{on})")), + serde_json::json!(expected) + ); + } + } +} + +#[test] +#[serial] +fn authored_targets_when_responsive_keep_every_level_without_conversion() { + let source = format!("{BOX}export const a=(rest)=>;"); + let compiled = output(&source); + let targets: std::collections::BTreeSet<_> = compiled + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(value) => { + Some((value.property(), value.level(), value.value())) + } + _ => None, + }) + .collect(); + assert_eq!( + targets, + std::collections::BTreeSet::from([ + ("padding-left", 1, "4px"), + ("padding-left", 2, "8px"), + ("padding-left", 3, "12px"), + ("padding-right", 1, "4px"), + ("padding-right", 2, "8px"), + ("padding-right", 3, "12px") + ]) + ); + assert_eq!(compiled.code.matches(".test(").count(), 0); + assert_eq!( + compiled.code.matches("\"px\" in ").count(), + 0, + "{}", + compiled.code + ); +} diff --git a/libs/extractor/src/jsx_semantics_tests/spread_grouping.rs b/libs/extractor/src/jsx_semantics_tests/spread_grouping.rs new file mode 100644 index 000000000..991b45546 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/spread_grouping.rs @@ -0,0 +1,96 @@ +use super::*; + +#[rstest] +#[case("")] +#[case("jsx(Box,{px:[null,1,2,3],...rest})")] +#[case("jsx(Box,{as:tag,px:[null,1,2,3],...rest})")] +#[serial] +fn grouped_spread_when_frontdoors_expand_targets_owns_one_authored_boundary(#[case] element: &str) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(rest,tag)=>{element};"); + let compiled = output(&source); + assert_eq!( + compiled.code.matches("\"px\" in ").count(), + 0, + "{}", + compiled.code + ); + assert_eq!( + compiled.code.matches(".test(").count(), + 0, + "{}", + compiled.code + ); + let actual = whole::evaluate( + &source, + "a({get px(){trace.push('get');return 2;}},'section')", + ); + assert_eq!(actual.trace, serde_json::json!(["get"])); + assert_eq!( + selected(&source, "a({ px:2 },'section')"), + serde_json::json!([null, "4px", "8px", "12px"]) + ); +} + +#[rstest] +#[case("")] +#[case("")] +#[case("jsx(Box,{px:[null,1,2],...rest,pl:7})")] +#[case("jsx(Box,{px:[null,1,2],pl:7,...rest})")] +#[serial] +fn grouped_spread_when_a_later_alias_shadows_half_keeps_only_surviving_targets( + #[case] element: &str, +) { + let source = format!("{BOX}{JSX_RUNTIME}export const a=(rest)=>{element};"); + let compiled = output(&source); + let winners: Vec<_> = compiled + .styles + .iter() + .filter_map(|style| match style { + ExtractStyleValue::Static(value) if value.property() == "padding-right" => { + Some((value.property(), value.level(), value.value())) + } + _ => None, + }) + .collect(); + assert_eq!( + winners, + vec![("padding-right", 1, "4px"), ("padding-right", 2, "8px")], + "{}", + compiled.code + ); + assert!(static_styles(&compiled).contains(&("padding-left".into(), "28px".into()))); + assert_eq!(compiled.code.matches("\"px\" in ").count(), 0); + assert_eq!(compiled.code.matches(".test(").count(), 0); +} + +#[test] +#[serial] +fn grouped_spread_when_the_key_is_written_again_preserves_the_later_boundary_and_effects() { + let source = format!( + "{BOX}export const a=(first,last)=>;" + ); + let compiled = output(&source); + let actual = whole::evaluate( + &source, + "a({get px(){trace.push('first');return 9;}},{get px(){trace.push('last');return 2;}})", + ); + assert_eq!( + actual.trace, + serde_json::json!(["old", "first", "new", "last"]) + ); + assert_eq!(compiled.code.matches("\"px\" in ").count(), 0); + assert_eq!(compiled.code.matches(".test(").count(), 0); + assert_eq!( + static_styles(&compiled), + vec![("padding-right".into(), "28px".into())] + ); + let control = whole::evaluate( + &source.replace(" {...first}", "").replace(" {...last}", ""), + "a({}, {})", + ); + assert_eq!( + actual.element["props"]["style"], + control.element["props"]["style"] + ); + assert_eq!(actual.element["props"]["px"], 2); +} diff --git a/libs/extractor/src/jsx_semantics_tests/spread_repair.rs b/libs/extractor/src/jsx_semantics_tests/spread_repair.rs new file mode 100644 index 000000000..58eddb862 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/spread_repair.rs @@ -0,0 +1,241 @@ +use super::*; +use crate::extract_style::ExtractStyleProperty; +use rstest::rstest; +use serial_test::serial; + +#[path = "spread_boundary.rs"] +mod boundary; +#[path = "spread_grouping.rs"] +mod grouping; + +fn selected(source: &str, probe: &str) -> serde_json::Value { + let compiled = output(source); + let actual = whole::evaluate(source, probe).element; + let classes: Vec<_> = actual["props"]["className"] + .as_str() + .unwrap_or("") + .split_whitespace() + .collect(); + let mut declarations = Vec::new(); + css::debug::set_debug(true); + for style in &compiled.styles { + let (level, value) = match style { + ExtractStyleValue::Static(style) => (style.level(), Some(style.value().to_string())), + ExtractStyleValue::Dynamic(style) => { + let StyleProperty::Variable { variable_name, .. } = style.extract(None) else { + panic!("dynamic variable"); + }; + let supplied = &actual["props"]["style"][&variable_name]; + let value = match supplied { + serde_json::Value::String(value) + if !value.is_empty() || style.property().starts_with("--") => + { + Some(value.clone()) + } + serde_json::Value::Number(value) => Some(value.to_string()), + _ => None, + }; + (style.level(), value) + } + _ => continue, + }; + let name = match style.extract(Some("a.tsx")) { + Some(StyleProperty::ClassName(name)) => name, + Some(StyleProperty::Variable { class_name, .. }) => class_name, + None => continue, + }; + if classes.contains(&name.as_str()) { + declarations.push((level, value)); + } + } + css::debug::set_debug(false); + declarations.sort_by_key(|(level, _)| *level); + serde_json::Value::Array( + [0, 1, 2, 4] + .into_iter() + .map(|viewport| { + declarations + .iter() + .rfind(|(level, _)| *level <= viewport) + .and_then(|(_, value)| value.as_deref()) + .map(|value| if value == "0px" { "0" } else { value }) + .map_or(serde_json::Value::Null, Into::into) + }) + .collect(), + ) +} + +#[rstest] +#[case("color=\"red\"", "color", "red")] +#[case("p={1}", "p", "4px")] +#[case("opacity={0.3}", "opacity", ".3")] +#[serial] +fn authored_styles_when_raw_values_are_absent_remain_unchanged( + #[case] attribute: &str, + #[case] key: &str, + #[case] fallback: &str, +) { + let source = format!("{BOX}export const a=(rest)=>;"); + for input in [ + "undefined", + "null", + "false", + "true", + "NaN", + "Infinity", + "-Infinity", + "''", + ] { + let actual = selected(&source, &format!("a({{{key}:{input}}})")); + assert_eq!( + actual, + serde_json::json!([fallback, fallback, fallback, fallback]), + "{input}" + ); + } + assert_eq!( + selected(&source, "a({})"), + serde_json::json!([fallback, fallback, fallback, fallback]) + ); +} + +#[rstest] +#[case("p", "1", "2")] +#[case("p", "1", "'2'")] +#[case("p", "1", "'12px'")] +#[case("p", "1", "0")] +#[case("p", "1", "-2")] +#[case("p", "1", "0.5")] +#[case("p", "1", "'.5'")] +#[case("p", "1", "'1.'")] +#[case("p", "1", "'1.e2'")] +#[case("p", "1", "'1e-2'")] +#[case("p", "1", "'+4'")] +#[case("p", "1", "'-0'")] +#[case("p", "1", "'0x10'")] +#[case("p", "1", "'1_000'")] +#[case("p", "1", "'4\\n'")] +#[case("animationDuration", "1", "2")] +#[case("animationDuration", "1", "'0.5'")] +#[case("opacity", "0.3", "2")] +#[serial] +fn raw_scalars_when_passed_through_leave_authored_units_unchanged( + #[case] key: &str, + #[case] fallback: &str, + #[case] input: &str, +) { + let source = format!("{BOX}export const a=(rest)=>;"); + let actual = selected(&source, &format!("a({{{key}:{input}}})")); + let expected = match key { + "p" => "4px", + "animationDuration" => "1ms", + "opacity" => ".3", + _ => panic!("unexpected property"), + }; + assert_eq!(actual, serde_json::json!(vec![expected; 4])); + assert_eq!( + whole::evaluate( + &source, + &format!( + "(()=>{{const raw={input};return Object.is(a({{{key}:raw}}).props.{key},raw);}})()" + ) + ) + .element, + true + ); +} + +#[rstest] +#[case("p={value()}")] +#[case("p={on ? '4px' : '8px'}")] +#[case("p={on ? '4px' : null}")] +#[case("p={on ? null : '8px'}")] +#[case("p={on && '4px'}")] +#[case("p={[1,2]}")] +#[case("p='4px' {...first}")] +#[case("padding='4px'")] +#[serial] +fn authored_selection_when_raw_spread_values_vary_keeps_original_branches(#[case] attribute: &str) { + let source = format!( + "{BOX}export const a=(rest,on)=>{{const first={{p:'20px'}};const value=()=> '5px';return ;}};" + ); + let key = if attribute.starts_with("padding") { + "padding" + } else { + "p" + }; + for on in [true, false] { + for input in ["2", "null", "undefined", "false", "''"] { + let actual = selected(&source, &format!("a({{{key}:{input}}},{on})")); + assert_eq!( + actual, + selected(&source, &format!("a({{}},{on})")), + "{attribute}/{on}/{input}" + ); + } + } +} + +#[test] +#[serial] +fn spread_repair_effects_when_spread_getters_run_preserves_native_order() { + let source = format!( + "{BOX}export const a=(rest)=> ;" + ); + let actual = whole::evaluate( + &source, + "(()=>{const s=Symbol('x');const rest=Object.create({ p: 9 });rest[s]=7;Object.defineProperty(rest,'p',{enumerable:true,get(){trace.push('get');return 2;}});const e=a(rest);return [Object.values(e.props.style),e.props[s],e.props.id];})()", + ); + assert_eq!(actual.element, serde_json::json!([["5px"], 7, "x"])); + assert_eq!(actual.trace, serde_json::json!(["value", "get", "id"])); + assert_eq!( + selected(&source, "a({ p:2 })"), + serde_json::json!(["5px", "5px", "5px", "5px"]) + ); +} + +#[rstest] +#[case("p={value()}", ["5px"; 4])] +#[case("p={on ? '4px' : null}", ["4px"; 4])] +#[case("p={on ? null : '8px'}", [""; 4])] +#[case("p={[1,2]}", ["4px", "8px", "8px", "8px"])] +#[case("p='4px' {...first}", ["20px"; 4])] +#[serial] +fn authored_values_when_spread_keys_are_missing_preserve_original_levels( + #[case] attribute: &str, + #[case] expected: [&str; 4], +) { + let source = format!( + "{BOX}export const a=(rest,on)=>{{const first={{p:'20px'}};const value=()=> '5px';return ;}};" + ); + let actual = selected(&source, "a({},true)"); + let expected = expected.map(|value| if value.is_empty() { None } else { Some(value) }); + assert_eq!(actual, serde_json::json!(expected)); +} + +#[test] +#[serial] +fn spread_frames_when_emitted_have_no_style_key_selection() { + for attribute in [ + "color='red'", + "p={1}", + "p={value()}", + "p={[1,2]}", + "p={on && '4px'}", + "p='4px' {...first}", + ] { + let source = format!( + "{BOX}export const a=(rest,on)=>{{const first={{p:'20px'}};const value=()=> '5px';return ;}};" + ); + let compiled = output(&source); + assert!(!compiled.code.contains("__devupNumber")); + assert!(!compiled.code.contains("-spread-")); + for key in ["color", "p"] { + assert!(!compiled.code.contains(&format!("\"{key}\" in "))); + } + assert_eq!( + selected(&source, "a({p:2,color:'raw'},true)"), + selected(&source, "a({},true)") + ); + } +} diff --git a/libs/extractor/src/jsx_semantics_tests/style_order.rs b/libs/extractor/src/jsx_semantics_tests/style_order.rs new file mode 100644 index 000000000..26c8bced6 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/style_order.rs @@ -0,0 +1,91 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn style_order_the_build_knows_is_kept() { + let rendered = code(&format!( + "{BOX}const ORDER = 3;\nexport const a = ;\nexport const b = ;\nexport const c = ;\nexport const d = ;\nexport const e = ;\nexport const f = ;\nexport const h = ;" + )); + + for order in ["--3-", "--5-", "--255-", "--1-", "--2-", "--7-"] { + assert!(rendered.contains(order), "{order}: {rendered}"); + } +} + +#[test] +#[serial] +fn empty_style_order_expression_is_a_located_parse_error() { + let message = error(&format!( + "{BOX}export const g = ;" + )); + assert!(message.starts_with("a.tsx:2:34:"), "{message}"); + assert!( + message.contains("JSX attributes must only be assigned a non-empty 'expression'"), + "{message}" + ); +} + +#[test] +#[serial] +fn a_runtime_style_order_is_a_located_error() { + let message = error(&format!( + "{BOX}export const a = (runtime) => ;" + )); + + assert!(message.starts_with("a.tsx:2:"), "{message}"); + assert!( + message.contains("`` cannot use `runtime`"), + "{message}" + ); +} + +#[test] +#[serial] +fn style_order_that_is_not_a_number_or_a_condition_of_numbers_is_an_error() { + for (order, written) in [ + ("{cond || 2}", "cond || 2"), + ("{cond ? 1 : other}", "cond ? 1 : other"), + ("{cond ? other : 2}", "cond ? other : 2"), + ("{cond && other}", "cond && other"), + ("\"abc\"", "\"abc\""), + ("
", ""), + ("<>", "<>..."), + ] { + let message = error(&format!( + "{BOX}export const a = (cond, other) => ;" + )); + + assert!( + message.contains(&format!("`` cannot use `{written}`")), + "{order}: {message}" + ); + } +} + +#[test] +#[serial] +fn a_call_with_a_runtime_style_order_is_a_located_error() { + for order in ["runtime", "cond ? 1 : runtime", "cond && runtime"] { + let message = error(&format!( + "{BOX}{JSX_RUNTIME}export const a = (cond, runtime) => jsx(Box, {{ styleOrder: {order}, color: 'red' }});" + )); + + assert!( + message.contains(&format!("cannot use `{order}`")), + "{order}: {message}" + ); + } +} + +#[test] +#[serial] +fn a_call_with_a_known_style_order_compiles() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a = (cond) => jsx(Box, {{ styleOrder: cond ? 2 : 4, color: 'red' }});\nexport const b = jsx(Box, {{ styleOrder: 6 as number, color: 'red' }});\nexport const c = jsx(Box, {{ color: 'red' }});" + )); + + for order in ["--2-", "--4-", "--6-"] { + assert!(rendered.contains(order), "{order}: {rendered}"); + } +} diff --git a/libs/extractor/src/jsx_semantics_tests/styled.rs b/libs/extractor/src/jsx_semantics_tests/styled.rs new file mode 100644 index 000000000..d1509c57f --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/styled.rs @@ -0,0 +1,125 @@ +use super::*; +use serial_test::serial; + +const CARD: &str = + "import { Box, styled } from '@devup-ui/react';\nconst Card = styled(Box, { bg: 'white' });\n"; + +fn card(element: &str) -> ExtractOutput { + output(&format!("{CARD}export const a = (props) => {element};")) +} + +#[test] +#[serial] +fn a_styled_component_renders_the_props_it_is_given_before_its_own_styles() { + let rendered = code( + "import { styled } from '@devup-ui/react';\nconst Card = styled('div', { color: 'red' });\nexport const a = (rest) => ;", + ); + + let spread = rendered + .find("{...") + .unwrap_or_else(|| panic!("{rendered}")); + let own = rendered + .find("className=") + .unwrap_or_else(|| panic!("{rendered}")); + assert!(spread < own, "{rendered}"); +} + +#[test] +#[serial] +fn an_explicit_style_after_a_spread_compiles_into_the_component() { + let output = card(""); + + assert!( + static_styles(&output).contains(&("color".to_string(), "red".to_string())), + "{}", + output.code + ); + assert!( + output.code.contains(""); + assert!(static_styles(&output).contains(&("color".into(), "red".into()))); + assert!(output.code.contains(""); + + assert!( + output + .code + .contains("const Card = __devupForwardRef((__devupRefProps, __devupRef) =>"), + "{}", + output.code + ); + assert!(output.code.contains("ref: __devupRef"), "{}", output.code); + assert!( + output.code.contains("\"theme\": __devupOmit0"), + "{}", + output.code + ); + assert!( + output.code.contains("background-0-white--255"), + "{}", + output.code + ); +} + +#[test] +#[serial] +fn repeated_spreads_give_the_last_own_key_to_a_styled_component() { + let output = card(""); + assert!(static_styles(&output).contains(&("color".into(), "blue".into()))); +} + +#[test] +#[serial] +fn what_the_component_configures_or_reads_stays_an_attribute() { + let rendered = code( + "import { Box, styled } from '@devup-ui/react';\nconst Card = styled(Box)`color: ${(p) => p.tone};`;\nexport const a = (props) => ;", + ); + + assert!(rendered.contains("as=\"a\""), "{rendered}"); + assert!(rendered.contains("tone=\"x\""), "{rendered}"); + assert!( + rendered.contains("theme={__devupValue") && rendered.contains("props.theme"), + "{rendered}" + ); + assert!(!rendered.contains("p={1}"), "{rendered}"); +} + +#[test] +#[serial] +fn a_component_extending_a_styled_one_takes_the_style_props_too() { + let output = output(&format!( + "{CARD}const Wide = styled(Card, {{ w: '100%' }});\nexport const a = (props) => ;" + )); + + assert!(static_styles(&output).contains(&("color".into(), "red".into()))); + assert!(!output.code.contains("color=\"red\""), "{}", output.code); +} + +#[test] +#[serial] +fn a_styled_component_of_a_tag_keeps_passing_what_it_always_did() { + let rendered = code( + "import { styled } from '@devup-ui/react';\nconst Card = styled('div', { bg: 'white' });\nexport const a = (props) => ;", + ); + + assert!(rendered.contains("id=\"x\""), "{rendered}"); + assert!(rendered.contains("color=\"red\""), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/type_imports.rs b/libs/extractor/src/jsx_semantics_tests/type_imports.rs new file mode 100644 index 000000000..cfe7e5ec3 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/type_imports.rs @@ -0,0 +1,75 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn a_type_only_import_declaration_stays_with_what_it_names() { + let rendered = code( + "import type { Box, css } from '@devup-ui/react';\nexport type A = typeof Box;\nexport type B = typeof css;", + ); + + assert!( + rendered.contains("import type { Box, css } from \"@devup-ui/react\""), + "{rendered}" + ); +} + +#[test] +#[serial] +fn a_type_only_default_and_namespace_import_stay() { + let rendered = code( + "import type Devup from '@devup-ui/react';\nimport type * as ns from '@devup-ui/react';\nexport type D = typeof Devup;\nexport type N = typeof ns.Box;", + ); + + assert!(rendered.contains("import type Devup from"), "{rendered}"); + assert!(rendered.contains("import type * as ns from"), "{rendered}"); +} + +#[test] +#[serial] +fn an_inline_type_specifier_stays_beside_the_compiled_ones() { + let rendered = code( + "import { Box, type Flex } from '@devup-ui/react';\nexport type F = typeof Flex;\nexport const a = ;", + ); + + assert!( + rendered.contains("import { type Flex } from \"@devup-ui/react\""), + "{rendered}" + ); + assert!(rendered.contains("
;\nexport const b = css({ color: 'blue' });", + ); + + assert!(rendered.contains(""), "{rendered}"); + assert!(rendered.contains("import type { Box }"), "{rendered}"); +} + +#[test] +#[serial] +fn type_specifiers_of_the_runtime_and_stylex_register_nothing() { + let rendered = code(&format!( + "{BOX}import {{ jsx, type jsxs }} from 'react/jsx-runtime';\nimport {{ type create }} from '@stylexjs/stylex';\nimport type {{ StyleXStyles }} from '@stylexjs/stylex';\nexport const a = jsx(Box, {{ color: 'red' }});\nexport type S = StyleXStyles | typeof create | typeof jsxs;" + )); + + assert!(rendered.contains("jsx(\"div\""), "{rendered}"); + assert!(rendered.contains("type jsxs"), "{rendered}"); + assert!(rendered.contains("type create"), "{rendered}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/whole.rs b/libs/extractor/src/jsx_semantics_tests/whole.rs new file mode 100644 index 000000000..b90adaa5f --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/whole.rs @@ -0,0 +1,60 @@ +//! Runs the whole code a file compiles to, as a bundler runs it, with a fake +//! React that records the props each element is built with. + +use super::*; +use oxc_allocator::Allocator; +use oxc_codegen::Codegen; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; +use oxc_transformer::{JsxRuntime, TransformOptions, Transformer}; +use std::path::Path; + +/// `code` with its types stripped and its JSX written as calls of `h` +fn plain_script(code: &str) -> String { + let allocator = Allocator::default(); + let mut program = Parser::new(&allocator, code, SourceType::tsx()) + .parse() + .program; + let scoping = SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut options = TransformOptions::default(); + options.jsx.runtime = JsxRuntime::Classic; + options.jsx.pragma = Some("h".to_string()); + let _ = Transformer::new(&allocator, Path::new("a.tsx"), &options) + .build_with_scoping(scoping, &mut program); + Codegen::new() + .build(&program) + .code + .lines() + .filter(|line| !line.starts_with("import ")) + .map(|line| line.strip_prefix("export ").unwrap_or(line)) + .collect::>() + .join("\n") +} + +/// What `probe` gives once the code `source` compiles to has run, as JSON, +/// and the `trace` that code wrote while it ran +pub(super) struct Evaluation { + pub element: serde_json::Value, + pub trace: serde_json::Value, +} + +/// Compile `source`, run what it compiles to, then evaluate `probe` with +/// `elements` building plain objects: `{ type, props, children }` +pub(super) fn evaluate(source: &str, probe: &str) -> Evaluation { + let compiled = output(source).code; + execute(&compiled, probe) +} + +pub(super) fn execute(compiled: &str, probe: &str) -> Evaluation { + let script = plain_script(compiled); + let joined = run(&format!( + "const trace = []; const h = (type, props, ...children) => ({{ type, props: props && {{ ...props }}, children }}); const jsx = (type, props) => ({{ type, props: props && {{ ...props }} }}); const createElement = h; const React = {{ createElement: h }}; const ReactNS = React; {script}\nreturn JSON.stringify([{probe}, trace]);" + )); + let (element, trace): (serde_json::Value, serde_json::Value) = + serde_json::from_str(&joined).unwrap_or_else(|error| panic!("{error}: {joined}")); + Evaluation { element, trace } +} diff --git a/libs/extractor/src/legacy_semantics_contract_tests.rs b/libs/extractor/src/legacy_semantics_contract_tests.rs new file mode 100644 index 000000000..69ad88915 --- /dev/null +++ b/libs/extractor/src/legacy_semantics_contract_tests.rs @@ -0,0 +1,140 @@ +use crate::{ExtractOption, extract}; +use css::class_map::reset_class_map; +use css::file_map::reset_file_map; + +fn error(filename: &str, source: &str, option: ExtractOption) -> String { + reset_class_map(); + reset_file_map(); + match extract(filename, source, option) { + Ok(output) => panic!("expected a build error, got {}", output.code), + Err(error) => error.to_string(), + } +} + +fn core_option() -> ExtractOption { + ExtractOption { + package: "@devup-ui/core".to_string(), + css_dir: "@devup-ui/core".to_string(), + single_css: true, + import_main_css: false, + import_aliases: std::collections::HashMap::new(), + } +} + +pub(super) fn extract_style_props_with_class_name() { + let source = r#"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core' +import clsx from 'clsx' + + +"#; + + let compiled = + extract("test.tsx", source, core_option()).unwrap_or_else(|error| panic!("{error}")); + assert!(compiled.code.contains("typo-buttonS"), "{}", compiled.code); + assert!(compiled.code.contains("typo-buttonM"), "{}", compiled.code); + assert!(compiled.code.contains("...props"), "{}", compiled.code); +} + +pub(super) fn extract_selector() { + let source = r"import {Center} from '@devup-ui/core' +
+ {children} +
+ "; + + let compiled = + extract("test.tsx", source, core_option()).unwrap_or_else(|error| panic!("{error}")); + for selector in ["&:active", "&:hover"] { + assert!(compiled.styles.iter().any(|style|matches!(style,crate::ExtractStyleValue::Static(value) if value.selector().is_some_and(|value|value.to_string().contains(selector)))),"{selector}: {}",compiled.code); + } +} + +pub(super) fn test_rest_props() { + let source = r"import { VStack } from '@devup-ui/core' + +export default function Card({ + children, + className, + ...props +}) { + return ( + + {children} + + ) +} + + "; + + let compiled = + extract("test.jsx", source, core_option()).unwrap_or_else(|error| panic!("{error}")); + assert!(compiled.styles.iter().any(|style|matches!(style,crate::ExtractStyleValue::Static(value) if value.value()=="scale(.95)")),"{}",compiled.code); + assert!(compiled.code.contains("...props"), "{}", compiled.code); +} + +pub(super) fn test_inline_local_constants() { + let source = r"import { Box, css, keyframes, globalCss } from '@devup-ui/react'; +const SIZE = 4; +const UNIT = `${SIZE}px`; +const DOUBLE = SIZE * 2; +const HALF = SIZE / 2 - 1; +const LABEL = 'a' + SIZE; +const TINY = 0.0000001; +const HUGE = 1e21; +const ZERO = -0; +const PX = 'px'; +const SELF = SELF; +let mutable = 1; +export const a = ; +export const b = css({ padding: `${SIZE * 2}px`, margin: SIZE - 1, width: SIZE * SIZE, height: (SIZE) }); +export const k = keyframes({ from: { opacity: SIZE / 8 } }); +globalCss({ body: { padding: UNIT } }); +export const c = ;"; + + let message = error("test.tsx", source, ExtractOption::default()); + + assert_eq!( + message, + "test.tsx:11:14: style value cannot use `SELF` at build time: it is read before its lexical declaration is initialized, so the original JavaScript throws ReferenceError; move the declaration of `SELF` above this use" + ); +} diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 81881f1b2..b3efa7e3b 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -24,6 +24,10 @@ mod imported_constants; mod imported_styled; mod jsx_import_source; mod jsx_pragmas; +#[cfg(test)] +mod jsx_semantics_tests; +#[cfg(test)] +mod legacy_semantics_contract_tests; mod module_loader; mod mutations; #[cfg(test)] @@ -605,6 +609,13 @@ fn extract_source( } else { imported_constants::Inlined::default() }; + if !inlined.errors.is_empty() { + let (source, earlier_edits) = evaluated.unwrap_or((code, &[])); + let edits: Vec<&[import_alias_visit::Edit]> = std::iter::once(alias_edits.as_slice()) + .chain(earlier_edits.iter().copied()) + .collect(); + return Err(located_errors(filename, source, &edits, inlined.errors).into()); + } dependencies.extend(inlined.dependencies); if !inlined.css_failures.is_empty() { let mut failures = inlined.css_failures; @@ -1304,8 +1315,23 @@ mod tests { ); }; let operand = r#"`"value:${+alias}"`"#; - assert_eq!((style.property(), style.identifier()), ("content", operand)); + assert_eq!(style.property(), "content"); assert_eq!(output.code.matches(operand).count(), 1); + let authored = format!("const alias={value};JSON.stringify([[],[{operand}]]);"); + let observation = Context::default() + .eval(Source::from_bytes(&authored)) + .unwrap() + .as_string() + .unwrap() + .to_std_string_escaped(); + assert_eq!( + render_observation( + &output, + "render()", + (!single_css).then_some("const-alias.js") + ), + observation + ); } #[rstest] @@ -2422,6 +2448,7 @@ mod tests { import clsx from 'clsx' "#, ExtractOption { @@ -2449,6 +2475,7 @@ import clsx from 'clsx' ) .unwrap() )); + crate::legacy_semantics_contract_tests::extract_style_props_with_class_name(); } #[test] @@ -3662,6 +3689,7 @@ import clsx from 'clsx' "test.tsx", r"import {Center} from '@devup-ui/core'
{children}
@@ -4035,6 +4062,7 @@ import clsx from 'clsx' ) .unwrap() )); + crate::legacy_semantics_contract_tests::extract_selector(); } #[test] @@ -5693,7 +5721,7 @@ e(o, { className: "a", bg: variable, style: { color: "blue" }, ...props }) reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from(extract("test.js", r#""use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("react/jsx-runtime"),{Box,Text,Flex}=require("@devup-ui/react");function t(){return e.jsxs("div",{children:[e.jsx(Box,{["_hover"]:{bg:"blue"},bg:"$text",[variable]:"red",children:"hello"})]})}exports.Lib=t;"#, 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())); + assert_debug_snapshot!(extract("test.js", r#""use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("react/jsx-runtime"),{Box,Text,Flex}=require("@devup-ui/react");function t(){return e.jsxs("div",{children:[e.jsx(Box,{["_hover"]:{bg:"blue"},bg:"$text",[variable]:"red",children:"hello"})]})}exports.Lib=t;"#, 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() }).map(ToBTreeSet::from).map_err(|error|error.to_string())); } #[test] @@ -5894,12 +5922,12 @@ export default function Card({ }) { return ( {children} @@ -5917,6 +5945,7 @@ export default function Card({ ) .unwrap() )); + crate::legacy_semantics_contract_tests::test_rest_props(); } #[test] @@ -7609,10 +7638,11 @@ export { .unwrap() )); - // Test 2: styleOrder={condition ? 5 : variable} — ternary with mixed static/dynamic + // Test 2: styleOrder={condition ? 5 : variable} — ternary with mixed static/dynamic, + // which the stylesheet cannot order by: a located build error reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.jsx", r#"import {Box} from '@devup-ui/core' @@ -7626,13 +7656,14 @@ export { import_aliases: HashMap::new() } ) - .unwrap() - )); + .map(ToBTreeSet::from) + .map_err(|error| error.to_string()) + ); - // Test 3: styleOrder={variable} — fully dynamic styleOrder + // Test 3: styleOrder={variable} — fully dynamic styleOrder, a located build error reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.jsx", r#"import {Box} from '@devup-ui/core' @@ -7646,8 +7677,9 @@ export { import_aliases: HashMap::new() } ) - .unwrap() - )); + .map(ToBTreeSet::from) + .map_err(|error| error.to_string()) + ); // Test 4: styleOrder={condition ? 5 : 10} with conditional style props // Verifies interaction between conditional styleOrder and conditional style values @@ -7867,10 +7899,10 @@ export { c as Lib };"#, )); // Coverage: utils.rs:100 — expression_to_style_order with plain variable (not conditional, not static) - // Also covers visit.rs:259,262 — fallback from pre-scan None to extract_style_from_expression's result + // The order is neither static nor conditional, so the call is a located build error reset_class_map(); reset_file_map(); - assert_debug_snapshot!(ToBTreeSet::from( + assert_debug_snapshot!( extract( "test.mjs", r#"import { jsx as e } from "react/jsx-runtime"; @@ -7887,8 +7919,9 @@ export { c as Lib };"#, import_aliases: HashMap::new() } ) - .unwrap() - )); + .map(ToBTreeSet::from) + .map_err(|error| error.to_string()) + ); // Coverage: visit.rs:297/492 — non-conditional static styleOrder via call expression (fallback _ branch) reset_class_map(); @@ -9955,7 +9988,7 @@ const TINY = 0.0000001; const HUGE = 1e21; const ZERO = -0; const PX = 'px'; -const SELF = SELF; +var SELF = SELF; let mutable = 1; export const a = ; export const b = css({ padding: `${SIZE * 2}px`, margin: SIZE - 1, width: SIZE * SIZE, height: (SIZE) }); @@ -9966,6 +9999,7 @@ export const c = ", - "className={(\"className\" in Object(rest) ? rest.className : \"direct\") || \"\"} style={\"style\" in Object(rest) ? rest.style : { opacity: 1 }}" + "className={(\"className\" in __devupSpread0 ? __devupSpread0.className : \"direct\") || \"\"} style={\"style\" in __devupSpread0 ? __devupSpread0.style : { opacity: 1 }}" )] #[case( "", - "className=\"last\"" + "className: \"last\"" )] #[case( "", - "className={{\n\tclassName: \"first\",\n\t...rest\n}.className || \"\"}" + "className: \"first\",\n\t\t...rest" )] #[case( " color=\"red\" />", @@ -15914,7 +15948,7 @@ const V = styled.div.attrs(extra)({ color: 'red' })", )] #[case( "", - "className={(\"className\" in Object(more) ? more.className : rest?.className) || \"\"}" + "className={(\"className\" in __devupSpread1 ? __devupSpread1.className : __devupSpread0?.className) || \"\"}" )] #[serial] fn test_jsx_props_written_later_win(#[case] element: &str, #[case] expected: &str) { @@ -16114,6 +16148,30 @@ export const d = css(ordered, danger, called);", assert_eq!(actual, expected, "{code}"); } + fn assert_external_composition(code: &str, single_css: bool) { + let function = &code[code + .find("function compose()") + .expect("fixture exports compose")..]; + let script = format!("{function}\ncompose();"); + let result = boa_engine::Context::default() + .eval(boa_engine::Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("{error}\n{script}")) + .as_string() + .unwrap() + .to_std_string_escaped(); + let actual = result + .split_whitespace() + .collect::>(); + let suffix = if single_css { "" } else { "-a" }; + let padding = format!("padding-0-8px--255{suffix}"); + let green = format!("color-0-green--255{suffix}"); + assert_eq!( + actual, + std::collections::BTreeSet::from(["external-card", padding.as_str(), green.as_str()]), + "{code}" + ); + } + #[rstest] #[case( "{ color: 'orange', p: 2 }", @@ -16434,7 +16492,7 @@ export const base = css(local, { bg: 'white' });", css::debug::set_debug(false); let output = output.unwrap(); // Then the nested external class stays opaque, not root style metadata. - assert!(output.code.contains("${alias}"), "{}", output.code); + assert_external_composition(&output.code, single_css); assert_composed_export_atoms( &output.code, "base", @@ -16674,15 +16732,7 @@ export const base = css(local, { bg: 'white' });", css::debug::set_debug(false); // Then the shadow stays opaque and the failed root import is nonfatal. let output = output.unwrap(); - assert!(output.code.contains("${alias}"), "{}", output.code); - let suffix = if single_css { "" } else { "-a" }; - assert!( - output.code.contains(&format!( - "return `padding-0-8px--255{suffix} color-0-green--255{suffix} ${{alias}}`;" - )), - "{}", - output.code - ); + assert_external_composition(&output.code, single_css); } #[rstest] @@ -16787,15 +16837,15 @@ export const base = css(local, { bg: 'white' });", )] #[case( "", - "
" + r"((__devupValue0, __devupValue1) =>
)(`${size}`, `${tone}`)" )] #[case( r"", - r"
" + r"((__devupValue0) =>
)(`${x}`)" )] #[case( "", - r#"
"# + r#"((__devupValue0) =>
)(`${on ? "a" : x}`)"# )] #[case( r#""#, @@ -16816,7 +16866,7 @@ export const base = css(local, { bg: 'white' });", #[case(r#""#, r#"
"#)] #[case( "", - "-
" + "-((__devupValue0, __devupValue1, __devupValue2) =>
)(`${name}`, `${a}`, `${b}`)" )] #[case( r#""#, @@ -16832,7 +16882,7 @@ export const base = css(local, { bg: 'white' });", )] #[case( "", - "-
" + "-((__devupValue0) =>
)(`${x}`)" )] #[case("", "-
")] #[serial] @@ -21391,10 +21441,6 @@ export const m = css({ right: proto.a, zIndex: partial.m, order: Level.Computed, "export const a = (k) => ;", "`` cannot use `[k]`", ), - ( - "export const a = (on, rest) => ;", - "`` cannot use `rest`", - ), ( "export const a = (list) => ;", "`` cannot use `[...list][0]`", @@ -21415,10 +21461,6 @@ export const m = css({ right: proto.a, zIndex: partial.m, order: Level.Computed, "export const a = (c, x) => ;", "`` cannot use `x`", ), - ( - "export const a = (rest) => ;", - "`` cannot use `rest`", - ), ( "declare const X: number;\nexport const a = css({ w: String(X) });", "`css()` cannot use `String(X)`", @@ -21436,6 +21478,19 @@ export const m = css({ right: proto.a, zIndex: partial.m, order: Level.Computed, .unwrap_or_default(); assert!(message.contains(error), "{code}\n{message}"); } + for source in [ + "export const a = (on, rest) => ;", + "export const a = (rest) => ;", + ] { + let compiled = extract( + "test.tsx", + &format!("import {{Box}} from '@devup-ui/react';{source}"), + ExtractOption::default(), + ) + .unwrap(); + assert_eq!(compiled.styles.len(), 0); + assert!(compiled.code.contains("...")); + } reset_class_map(); reset_file_map(); assert_debug_snapshot!(ToBTreeSet::from( @@ -21776,7 +21831,17 @@ export const a = ;", &memory_resolver(modules), ) .unwrap(); - assert!(imported.code.contains(")(make(5))"), "{}", imported.code); + assert_eq!( + imported.code.matches("make(5)").count(), + 1, + "{}", + imported.code + ); + assert!( + imported.code.contains("__proto__: null"), + "{}", + imported.code + ); } #[test] diff --git a/libs/extractor/src/numeric_template_tests/execution.rs b/libs/extractor/src/numeric_template_tests/execution.rs index 2ee436cc3..2ab9df61f 100644 --- a/libs/extractor/src/numeric_template_tests/execution.rs +++ b/libs/extractor/src/numeric_template_tests/execution.rs @@ -39,11 +39,29 @@ fn effectful_template_executes_once_when_generated_element_renders( }, ) .unwrap_or_else(|error| panic!("runtime fixture extraction failed: {error}")); + // When: execute one render, not a reconstructed interpolation expression. + let result = observe(&output, "render(5, trace)"); + // Then: the mutation happens once and the complete value reaches the style variable. + assert_eq!(result, r#"[["value"],["0.5"]]"#); +} + +pub(super) fn observe(output: &crate::ExtractOutput, invocation: &str) -> String { let arena = Allocator::default(); - let parsed = Parser::new(&arena, &output.code, SourceType::mjs()).parse(); + let parsed = Parser::new(&arena, &output.code, SourceType::tsx()).parse(); assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); let mut program = parsed.program; - let mut factories = Vec::new(); + let scoping = oxc_semantic::SemanticBuilder::new() + .build(&program) + .semantic + .into_scoping(); + let mut options = oxc_transformer::TransformOptions::default(); + options.jsx.runtime = oxc_transformer::JsxRuntime::Classic; + options.jsx.pragma = Some("__numericJsx".to_string()); + let transformed = + oxc_transformer::Transformer::new(&arena, std::path::Path::new("numeric.tsx"), &options) + .build_with_scoping(scoping, &mut program); + assert_eq!(transformed.diagnostics.len(), 0); + let mut factories = vec!["const __numericJsx=(target,props)=>({target,props});".to_string()]; for statement in &program.body { if let Statement::ImportDeclaration(import) = statement && import.source.value == "react/jsx-runtime" @@ -81,19 +99,65 @@ fn effectful_template_executes_once_when_generated_element_renders( .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); let factories = factories.join("\n"); let script = format!( - "{factories}{}\nconst trace = []; const result = render(5, trace); JSON.stringify([trace,Object.values(result.props.style || {{}})]);", + "{factories}{}\nconst trace = []; const result = {invocation}; JSON.stringify([trace,Object.values(result.props.style || {{}})]);", Codegen::new().build(&program).code ); - // When: execute one render, not a reconstructed interpolation expression. let result = Context::default() .eval(Source::from_bytes(&script)) .unwrap_or_else(|error| panic!("generated renderer failed: {error}\n{script}")); - // Then: the mutation happens once and the complete value reaches the style variable. - assert_eq!( - result - .as_string() - .unwrap_or_else(|| panic!("renderer must return JSON text")) - .to_std_string_escaped(), - r#"[["value"],["0.5"]]"# + result + .as_string() + .unwrap_or_else(|| panic!("renderer must return JSON text")) + .to_std_string_escaped() +} + +#[rstest] +#[serial] +fn coercion_and_throw_order_survive_when_complete_template_frames_execute( + #[values(Surface::Box, Surface::Styled, Surface::Responsive)] surface: Surface, + #[values(false, true)] throws: bool, + #[values(false, true)] single_css: bool, +) { + // Given: a coercible interpolation and a later effect that a throw must skip. + reset_class_map(); + reset_file_map(); + let expression = "`0.${(trace.push('value'),value)}${(trace.push('later'),'')}`"; + let statement = match surface { + Surface::Box => format!("return jsx(Box,{{opacity:{expression}}});"), + Surface::Responsive => format!("return jsx(Box,{{opacity:[1,null,{expression}]}});"), + Surface::Styled => { + format!("const Card=styled.div({{opacity:{expression}}});return Card({{}});") + } + Surface::Css => unreachable!(), + }; + let source = format!( + "import {{Box,styled}} from '@devup-ui/react';import {{jsx}} from 'react/jsx-runtime';function render(value,trace){{{statement}}}" + ); + let value = format!( + "const value={{[Symbol.toPrimitive](hint){{trace.push(hint);if({throws})throw new Error('stop');return 5;}}}};" + ); + let authored = format!( + "const trace=[];{value}let values=[];try{{values=[{expression}];}}catch(e){{trace.push(e.message);}}JSON.stringify([trace,values]);" + ); + let expected = Context::default() + .eval(Source::from_bytes(&authored)) + .unwrap_or_else(|error| panic!("authored coercion failed: {error}")) + .as_string() + .unwrap_or_else(|| panic!("authored coercion must return JSON")) + .to_std_string_escaped(); + // When: run generated declarations and their owning calls, including their catch boundary. + let output = extract( + "coercion.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap_or_else(|error| panic!("coercion extraction failed: {error}")); + let invocation = format!( + "(()=>{{{value}try{{return render(value,trace);}}catch(e){{trace.push(e.message);return {{props:{{}}}};}}}})()" ); + // Then: native interpolation coercion runs once, and only successful coercion reaches later. + assert_eq!(observe(&output, &invocation), expected, "{}", output.code); } diff --git a/libs/extractor/src/numeric_template_tests/mod.rs b/libs/extractor/src/numeric_template_tests/mod.rs index c895fbdc8..57b909e83 100644 --- a/libs/extractor/src/numeric_template_tests/mod.rs +++ b/libs/extractor/src/numeric_template_tests/mod.rs @@ -113,41 +113,21 @@ fn template_values_preserve_interpolations_when_extracted( (style.property(), style.level(), style.selector()), (property, level, None) ); - match surface { - Surface::Box | Surface::Responsive => { - let arena = Allocator::default(); - let input = Parser::new(&arena, expression, SourceType::mjs()) - .parse_expression() - .unwrap_or_else(|error| { - panic!("invalid input expression: {error:?}") - }); - let retained = - Parser::new(&arena, style.identifier(), SourceType::mjs()) - .parse_expression() - .unwrap_or_else(|error| { - panic!("invalid dynamic expression: {error:?}") - }); - let evaluate = |expression| { - let script = format!( - "const trace=[]; const value=5; JSON.stringify([{expression},trace]);" - ); - Context::default() - .eval(Source::from_bytes(&script)) - .unwrap_or_else(|error| { - panic!("template evaluation failed: {error}") - }) - .as_string() - .unwrap_or_else(|| panic!("template must return JSON text")) - .to_std_string_escaped() - }; - assert_eq!( - evaluate(crate::utils::readable_code(&retained)), - evaluate(crate::utils::readable_code(&input)) - ); - } - Surface::Styled => assert_ne!(style.identifier(), ""), + let invocation = match surface { + Surface::Box | Surface::Responsive => "render(5, trace)", + Surface::Styled => "render(5, trace)({})", Surface::Css => unreachable!("runtime css must fail extraction"), - } + }; + let authored = format!( + "const trace=[];const value=5;JSON.stringify([trace,[{expression}]]);" + ); + let expected = Context::default() + .eval(Source::from_bytes(&authored)) + .unwrap_or_else(|error| panic!("authored template failed: {error}")) + .as_string() + .unwrap_or_else(|| panic!("authored template must return JSON")) + .to_std_string_escaped(); + assert_eq!(execution::observe(&output, invocation), expected); } other => panic!("unexpected template style: {other:?}"), } diff --git a/libs/extractor/src/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index 494d8f62a..2938813ac 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -19,6 +19,9 @@ use oxc_span::SPAN; use std::borrow::Cow; use std::collections::BTreeSet; +#[cfg(test)] +mod written_tests; + /// Combine two optional className expressions into a conditional expression. /// `condition ? con_expr : alt_expr`, falling back to `""` for the missing branch. /// Returns `None` only when both branches are `None`. @@ -522,15 +525,8 @@ pub fn get_class_name_expression<'a>( style_order: Option, filename: Option<&str>, ) -> (Option>, Vec) { - let mut tailwind = TailwindClassName { - style_order, - filename, - styles: Vec::new(), - rules: BTreeSet::new(), - }; - let compiled = class_name_prop - .as_ref() - .and_then(|class_name| tailwind.compile_expression(ast_builder, class_name)); + let (tailwind_styles, compiled) = + compile_tailwind_class_name(ast_builder, class_name_prop.as_ref(), style_order, filename); // A rebuilt `cond && "a"` still evaluates to `false`, which React would // render as `class="false"`, so it needs the same falsy guard as a passthrough. let class_name_to_use = compiled @@ -548,7 +544,25 @@ pub fn get_class_name_expression<'a>( } let expression = merge_string_expressions(ast_builder, &class_expressions); - (expression, tailwind.into_styles()) + (expression, tailwind_styles) +} + +/// Returns extracted Tailwind styles and a replacement only when classes compiled. +pub(crate) fn compile_tailwind_class_name<'a>( + ast_builder: &AstBuilder<'a>, + class_name: Option<&Expression<'a>>, + style_order: Option, + filename: Option<&str>, +) -> (Vec, Option>) { + let mut tailwind = TailwindClassName { + style_order, + filename, + styles: Vec::new(), + rules: BTreeSet::new(), + }; + let compiled = + class_name.and_then(|class_name| tailwind.compile_expression(ast_builder, class_name)); + (tailwind.into_styles(), compiled) } /// Compiles the Tailwind classes of a `cva(base, { variants, compoundVariants })` @@ -998,11 +1012,15 @@ fn last_written<'a>( SPAN, Expression::new_string_literal(SPAN, key, None, ast_builder), oxc_syntax::operator::BinaryOperator::In, - crate::utils::wrap_direct_call( - ast_builder, - &Expression::new_identifier(SPAN, "Object", ast_builder), - &[spread.clone_in(ast_builder.allocator())], - ), + if snapshot_reference(spread) { + spread.clone_in(ast_builder.allocator()) + } else { + crate::utils::wrap_direct_call( + ast_builder, + &Expression::new_identifier(SPAN, "Object", ast_builder), + &[spread.clone_in(ast_builder.allocator())], + ) + }, ast_builder, ); Expression::new_conditional_expression( @@ -1019,6 +1037,13 @@ fn last_written<'a>( value } +/// Synthetic hygienic names identify null-prototype data snapshots, not user bindings. +fn snapshot_reference(expression: &Expression<'_>) -> bool { + matches!(expression, Expression::Identifier(identifier) + if identifier.span == SPAN && identifier.reference_id.get().is_none() + && identifier.name.starts_with("__devupSpread")) +} + /// Whether the object literal `spread` names `key`, so it always holds it fn names(spread: &Expression<'_>, key: &str) -> bool { let Expression::ObjectExpression(object) = crate::utils::unwrap_syntax_only(spread) else { diff --git a/libs/extractor/src/prop_modify_utils/written_tests.rs b/libs/extractor/src/prop_modify_utils/written_tests.rs new file mode 100644 index 000000000..28846344e --- /dev/null +++ b/libs/extractor/src/prop_modify_utils/written_tests.rs @@ -0,0 +1,56 @@ +use super::*; +use oxc_allocator::Allocator; +use oxc_ast::ast::Statement; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rstest::rstest; + +#[rstest] +#[case("({className:'blue'})", "", "blue")] +#[case("({className:'blue',id:'x'})", "", "blue")] +#[case("({id:'x'})", "", "base")] +#[case( + "({className:'red',...rest})", + "const rest={className:'blue'};", + "blue" +)] +#[case("({className:'red',...rest})", "const rest={};", "red")] +#[case("({...rest})", "const rest={className:'blue'};", "blue")] +#[case("({...rest})", "const rest={};", "base")] +#[case("({[key]:'blue'})", "const key='className';", "blue")] +#[case("({[key]:'blue'})", "const key='id';", "base")] +#[case("({className:undefined})", "", "undefined")] +fn written_class_when_a_literal_spread_follows_an_explicit_value_obeys_assignment( + #[case] source: &str, + #[case] setup: &str, + #[case] expected: &str, +) { + let allocator = Allocator::default(); + let ast = AstBuilder::new(&allocator); + let parsed = Parser::new(&allocator, source, SourceType::ts()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("expression required") + }; + let written = [ + Written::Prop(Expression::new_string_literal(SPAN, "base", None, &ast)), + Written::Spread(statement.expression.clone_in(ast.allocator())), + ]; + let expression = last_written(&ast, &written, "className") + .unwrap_or_else(|| panic!("the explicit class is present")); + let script = format!( + "(()=>{{{setup}return ({});}})()", + crate::utils::readable_code(&expression) + ); + let mut context = boa_engine::Context::default(); + let actual = context + .eval(boa_engine::Source::from_bytes(script.as_bytes())) + .unwrap_or_else(|error| panic!("{error}: {script}")); + assert_eq!( + actual + .to_string(&mut context) + .unwrap_or_else(|error| panic!("{error}")) + .to_std_string_escaped(), + expected + ); +} diff --git a/libs/extractor/src/scope/lookup.rs b/libs/extractor/src/scope/lookup.rs index 018248691..287f33e69 100644 --- a/libs/extractor/src/scope/lookup.rs +++ b/libs/extractor/src/scope/lookup.rs @@ -38,7 +38,7 @@ impl Bindings { /// The component `expression` reads pub fn kind(&self, expression: &Expression<'_>) -> Option { - match expression { + match crate::utils::unwrap_syntax_only(expression) { Expression::Identifier(identifier) => self .symbol(identifier) .and_then(|symbol| self.imports.get(&symbol)) @@ -73,6 +73,12 @@ impl Bindings { .is_some_and(|symbol| self.namespaces.contains(&symbol)) } + /// Whether `identifier` reads the global `undefined`, not a binding of + /// that name + pub fn is_global_undefined(&self, identifier: &IdentifierReference<'_>) -> bool { + identifier.name == "undefined" && self.symbol(identifier).is_none() + } + /// Whether the element `name` is the `Global` component pub fn is_global_component(&self, name: &JSXElementName<'_>) -> bool { match name { diff --git a/libs/extractor/src/scope/required.rs b/libs/extractor/src/scope/required.rs index 0cacf42ee..72256bb8b 100644 --- a/libs/extractor/src/scope/required.rs +++ b/libs/extractor/src/scope/required.rs @@ -18,7 +18,7 @@ impl Bindings { if callee.name != "require" || self.symbol(callee).is_some() { return; } - let from_package = if source.value == "react/jsx-runtime" { + let from_package = if matches!(source.value.as_str(), "react/jsx-runtime" | "react") { false } else if source.value == package { true diff --git a/libs/extractor/src/scope/tests/diagnostics.rs b/libs/extractor/src/scope/tests/diagnostics.rs index 44e102ceb..80047fefc 100644 --- a/libs/extractor/src/scope/tests/diagnostics.rs +++ b/libs/extractor/src/scope/tests/diagnostics.rs @@ -67,7 +67,7 @@ fn changed_top_level_binding_is_reported_but_a_local_of_the_same_name_is_not() { const base = { p: 4 };\nbase.p = 8;\n\ export const a = ;", ); - assert!(element.is_err_and(|error| error.contains("`` cannot use `base`"))); + assert!(element.is_ok_and(|code| code.contains("...base") && !code.contains("--padding"))); let shadowed = extracted( "import { css, Box } from '@devup-ui/react';\n\ @@ -79,7 +79,8 @@ fn changed_top_level_binding_is_reported_but_a_local_of_the_same_name_is_not() { shadowed .as_ref() .is_ok_and(|code| code.contains("export const real = \"a-a\"") - && code.contains("
Visit<'a> for CompiledReads<'_> { } fn visit_call_expression(&mut self, it: &CallExpression<'a>) { + let props = it.arguments.get(1); + let builds_element = self + .bindings + .jsx_function(&it.callee) + .is_some_and(|function| match function.as_str() { + "createElement" => props.is_none_or(Argument::is_expression), + "jsx" | "jsxs" | "jsxDEV" => props.is_some_and(Argument::is_expression), + _ => false, + }); if self.before_lowering - && self - .bindings - .jsx_function(&it.callee) - .is_some_and(|function| matches!(function.as_str(), "jsx" | "jsxs" | "jsxDEV")) - && it.arguments.get(1).is_some_and(Argument::is_expression) + && builds_element && it .arguments .first() diff --git a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_give_css_props_what_local_ones_give.snap b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_give_css_props_what_local_ones_give.snap index 9c039a474..603a981ff 100644 --- a/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_give_css_props_what_local_ones_give.snap +++ b/libs/extractor/src/snapshots/extractor__imported_styled__tests__imported_components_give_css_props_what_local_ones_give.snap @@ -6,5 +6,5 @@ expression: outputs "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nimport { Base, Dyn } from \"./base\";\nexport const C = () =>
;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n \"Static(ExtractStaticStyle { property: \\\"padding\\\", value: \\\"4px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", "/src/app.tsx:3:30: `css` on `` overrides styles `Base` sets, which the build orders only for a styled component rendering a tag with no attrs or props read, given no spread, `as` or `forwardedAs`: move these styles into `styled(Base)(...)`", "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nimport { Base, Dyn } from \"./base\";\nexport const C = () => ;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"color\\\", value: \\\"blue\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", - "/** @jsxImportSource react */\nimport \"@devup-ui/react/devup-ui.css\";\nimport { Base, Dyn } from \"./base\";\nexport const C = ({ rest }) => ;\n\n{\n \"Static(ExtractStaticStyle { property: \\\"margin\\\", value: \\\"1px\\\", level: 0, selector: None, style_order: None, layer: None })\",\n}\n[\"/src/base.ts\"]", + "/src/app.tsx:3:58: `css` on `` cannot use `...rest` at build time: a spread after the `css` prop may carry a `css` of its own, which replaces it whole where only the runtime could tell: write the spread before `css`", ] diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-3.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-3.snap index b2502eba3..ccd0dd6f1 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-3.snap @@ -1,8 +1,8 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Text} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Text} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: {}, - code: ";\n", + code: "((__devupValue0) => )(`${bo ? a : b}`);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap index 35b02b3a0..a85cf2d4c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-5.snap @@ -17,5 +17,5 @@ ToBTreeSet { "tag", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\n {children}\n ;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupChoice0) => \n {children}\n )({\n\tlg: [\"lg\"],\n\tmd: [\"md\"],\n\tsm: [\"sm\"],\n\ttag: [\"tag\"]\n}[size]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap index ba70c53a2..46d59ca12 100644 --- a/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__apply_var_typography-6.snap @@ -17,5 +17,5 @@ ToBTreeSet { "tag", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\n {children}\n ;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupChoice0) => \n {children}\n )({\n\tlg: [\"lg\"],\n\tmd: [\"md\"],\n\tsm: [\"sm\"],\n\ttag: [\"tag\"]\n}[size]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__backtick_prop-2.snap b/libs/extractor/src/snapshots/extractor__tests__backtick_prop-2.snap index 333ae89e0..1672e8c1c 100644 --- a/libs/extractor/src/snapshots/extractor__tests__backtick_prop-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__backtick_prop-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Box} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr\"import {Box} from '@devup-ui/core'\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "`${variable}`", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(`${variable}`);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap index 99505d10d..7f1af0cf6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap @@ -5,23 +5,23 @@ expression: outputs [ "Error: /src/App.tsx:4:22: `css()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:1: `base` is changed here, so the build cannot read it as a constant", "Error: /src/App.tsx:5:18: `css()` cannot use `base.p` at build time: its values must be literals, theme tokens or constants, or be computed from them\n/src/App.tsx:3:15: `base` is changed here, so the build cannot read it as a constant", - "Error: /src/App.tsx:4:23: `` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:5:29: `styled()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:6:22: `css()` cannot use `x ? base : null` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:7:18: Cannot compose `x ? null : [y && base]` at build time: each style must be a rule object, a class, or a condition choosing between them\n/src/App.tsx:7:22: `css()` cannot use `x ? null : [y && base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:8:22: `css()` cannot use `...[base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:10: `base` is handed here to code that may change it, so the build cannot read it as a constant", + "Error: /src/App.tsx:5:29: `styled()` cannot use `base` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:6:22: `css()` cannot use `x ? base : null` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:7:18: Cannot compose `x ? null : [y && base]` at build time: each style must be a rule object, a class, or a condition choosing between them\n/src/App.tsx:7:22: `css()` cannot use `x ? null : [y && base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:8:22: `css()` cannot use `...[base]` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/App.tsx:3:10: `base` is handed here to code that may change it, so the build cannot read it as a constant", "Error: /src/App.tsx:6:18: `css()` cannot use `baseline` at build time: its values must be literals, theme tokens or constants, or be computed from them", "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nconst hover = darken(colors.primary);\nexport const a =
;\n", "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nexport const theme = { colors };\nexport const a =
;\n", - "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nexport const theme = { colors };\ntheme.colors.primary = \"blue\";\nexport const a =
;\n", - "import \"@devup-ui/react/devup-ui-0.css\";\nconst loop1 = { x: loop2 };\nconst loop2 = { y: loop1 };\nexport const a =
;\n", - "import \"@devup-ui/react/devup-ui-0.css\";\nconst list = [{ p: 1 }];\nwatch(...list);\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst colors = { primary: \"red\" };\nexport const theme = { colors };\ntheme.colors.primary = \"blue\";\nexport const a = ((__devupValue0) =>
)(colors.primary);\n", + "Error: /src/App.tsx:2:20: style value cannot use `loop2` at build time: it is read before its lexical declaration is initialized, so the original JavaScript throws ReferenceError; move the declaration of `loop2` above this use", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst list = [{ p: 1 }];\nwatch(...list);\nexport const a = ((__devupValue0) =>
)(list[0].p);\n", "import \"@devup-ui/react/devup-ui-0.css\";\nconst flat = [1, 2];\nwatch(...flat);\nexport const a =
;\n", - "import \"@devup-ui/react/devup-ui-0.css\";\nconst partial = {\n\t...unknown,\n\tp: 1\n};\nwatch(partial.q);\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst partial = {\n\t...unknown,\n\tp: 1\n};\nwatch(partial.q);\nexport const a = ((__devupValue0) =>
)(partial.p);\n", "import \"@devup-ui/react/devup-ui-0.css\";\nconst record = { p: 1 };\nwatch(record.q);\nexport const a =
;\n", - "import \"@devup-ui/react/devup-ui-0.css\";\nconst spread = {\n\t...unknown,\n\tp: 1\n};\nwatch({ ...spread });\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nconst spread = {\n\t...unknown,\n\tp: 1\n};\nwatch({ ...spread });\nexport const a = ((__devupValue0) =>
)(spread.p);\n", "import \"@devup-ui/react/devup-ui-0.css\";\nexport enum E {\n\tA = 1\n}\nenum F {\n\tB = 2\n}\nexport function g() {}\nvar v = 1;\nconst make = () => ({ p: 1 });\nconst made = make();\nwatch(made);\nconst f = () => made.p;\nexport const a = \"a-b\";\n", "Error: /src/App.tsx:6:18: `css()` cannot use `f()` at build time: its values must be literals, theme tokens or constants, or be computed from them", "Error: /src/App.tsx:3:18: `css()` cannot use `pick()` at build time: its values must be literals, theme tokens or constants, or be computed from them", "Error: /src/App.tsx:3:18: `css()` cannot use `brand()` at build time: its values must be literals, theme tokens or constants, or be computed from them", "Error: /src/App.tsx:3:18: `css()` cannot use `node()` at build time: its values must be literals, theme tokens or constants, or be computed from them", "Error: /src/App.tsx:4:18: `css()` cannot use `f()` at build time: its values must be literals, theme tokens or constants, or be computed from them", - "import \"@devup-ui/react/devup-ui-0.css\";\nimport base, { fixed, colors, grid, renamed } from \"./tokens\";\nimport * as tokens from \"./tokens\";\nexport const a =
;\n", + "import \"@devup-ui/react/devup-ui-0.css\";\nimport base, { fixed, colors, grid, renamed } from \"./tokens\";\nimport * as tokens from \"./tokens\";\nexport const a = ((__devupValue0) =>
)(base.p);\n", "Error: /src/App.tsx:3:22: `css()` cannot use `again` at build time: its styles must be an object literal or a constant object, or be computed from constants\n/src/tokens.ts:10:26: `base` is changed here, so the build cannot read it as a constant", ] diff --git a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap index 9b0e29e38..70605caa7 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compose_rules_the_module_computes.snap @@ -75,5 +75,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-e\";\nexport const b = \"a-a a-f\";\nexport const c = `named x ${`y`}`;\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const g = (on) => `a-e a-b ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-c\";\nexport const i = \"a-e\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-d ${emotionClass}`;\nexport const n = ;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as tokens from \"./styles\";\nimport { card, rules } from \"./styles\";\nimport { emotionClass } from \"./emotion\";\nconst make = (n: number) => ({ m: n });\nconst local = make(2);\nconst computed = make(3);\nconst name = String(\"named\");\nconst shared = {\n\trules: make(4),\n\tfixed: { p: 5 }\n};\nexport const a = \"a-e\";\nexport const b = \"a-a a-f\";\nexport const c = \"named x y\";\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"span\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-f\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"p\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--f a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"note\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const g = (on) => `a-e a-b ${on ? typeof on === \"string\" ? on : \"\" : \"\"}`;\nexport const h = \"a-c\";\nexport const i = \"a-e\";\nexport const k = `k ${tokens.card}`;\nexport const l = `a-d ${emotionClass}`;\nexport const n = ;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap index bdeee1ae0..09bacc779 100644 --- a/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap +++ b/libs/extractor/src/snapshots/extractor__tests__compute_what_elements_and_styled_objects_take.snap @@ -99,5 +99,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =
1} className=\"a-c\" />;\nexport const b =
;\nexport const c =
;\nexport const d =
;\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-f a-e a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = \"a-h a-a\";\nexport const g = \"a-i\";\nexport const h =
;\nfunction local() {\n\treturn { p: 7 };\n}\nexport const i =
;\nexport const j = (register) => ((__devupSpread0) =>
)(register(\"email\"));\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport { PRIMARY } from \"./tokens\";\nconst make = (n: number) => ({ m: n });\nconst KEY = [\"co\", \"lor\"].join(\"\");\nfunction darken(amount: number, color: string) {\n\treturn color === PRIMARY ? \"#112233\" : color;\n}\nconst card = make(2);\nconst hover = make(3);\nconst parts = [make(4), { p: 1 }];\nexport const a =
1} className=\"a-b\" />;\nexport const b =
;\nexport const c =
;\nexport const d =
;\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-f a-d a-g\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = \"a-h a-e\";\nexport const g = \"a-i\";\nexport const h =
;\nfunction local() {\n\treturn { p: 7 };\n}\nexport const i =
;\nexport const j = (register) => ((__devupSpread0) =>
)({\n\t__proto__: null,\n\t...register(\"email\")\n});\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__conditional_styles.snap b/libs/extractor/src/snapshots/extractor__tests__conditional_styles.snap index 619436475..1e9af21c2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__conditional_styles.snap +++ b/libs/extractor/src/snapshots/extractor__tests__conditional_styles.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst Component = () => {\n const isActive = true;\n return ;\n}\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Box} from '@devup-ui/core'\nconst Component = () => {\n const isActive = true;\n return ;\n}\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: false, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,16 +14,6 @@ ToBTreeSet { layer: None, }, ), - Static( - ExtractStaticStyle { - property: "color", - value: "blue", - level: 0, - selector: None, - style_order: None, - layer: None, - }, - ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst Component = () => {\n\tconst isActive = true;\n\treturn
;\n};\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nconst Component = () => {\n\tconst isActive = true;\n\treturn
;\n};\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap index 163dbf50e..447438a36 100644 --- a/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap +++ b/libs/extractor/src/snapshots/extractor__tests__constant_objects_arrays_and_enums.snap @@ -283,7 +283,7 @@ Ok( ExtractDynamicStyle { property: "bottom", level: 0, - identifier: "getter.a", + identifier: "__devupValue0", selector: None, style_order: None, }, @@ -305,6 +305,6 @@ Ok( }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { space, Size, Local, focus } from \"./tokens\";\nconst base = {\n\tp: 4,\n\tcolor: \"red\",\n\tcolor: \"blue\"\n};\nconst hover = { opacity: .5 };\nconst merged = {\n\t...base,\n\tm: 1\n};\nconst partial = {\n\t...unknown,\n\tm: 1\n};\nconst proto = {\n\t__proto__: null,\n\ta: 1\n};\nconst getter = { get a() {\n\treturn 1;\n} };\nexport const withGetter =
;\nconst NONE = null;\nconst MEDIA = \"@media (min-width: 1px)\";\nconst keyed = { [MEDIA]: { color: \"green\" } };\nconst widths = [\n\t10,\n\tnull,\n\t20\n];\nconst more = [...widths, 30];\nconst bad = [...unknown];\nenum Level {\n\tLow = 1,\n\tHigh,\n\tName = \"n\",\n\tComputed = Math.max(1, 2),\n\tLast = 9\n}\ndeclare enum Ambient {\n\tX = 1\n}\nconst sx = { color: \"red\" };\nexport const a = \"a-b a-c\";\nexport const b = \"a-b a-d a-e a-f\";\nexport const c = \"a-b a-c a-g\";\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-b a-c a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = (Comp, handler) => ;\nexport const g =
;\nexport const h = \"a-b a-c\";\nexport const i = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--i a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const j = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--j a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const k = { \"x\": \"a-s\" };\n;\nexport const l = \"a-t\";\nexport const m = \"a-u a-v a-y a-z\";\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport { forwardRef as __devupForwardRef } from \"react\";\nimport * as Devup from \"@devup-ui/react\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { space, Size, Local, focus } from \"./tokens\";\nconst base = {\n\tp: 4,\n\tcolor: \"red\",\n\tcolor: \"blue\"\n};\nconst hover = { opacity: .5 };\nconst merged = {\n\t...base,\n\tm: 1\n};\nconst partial = {\n\t...unknown,\n\tm: 1\n};\nconst proto = {\n\t__proto__: null,\n\ta: 1\n};\nconst getter = { get a() {\n\treturn 1;\n} };\nexport const withGetter = ((__devupValue0) =>
)(getter.a);\nconst NONE = null;\nconst MEDIA = \"@media (min-width: 1px)\";\nconst keyed = { [MEDIA]: { color: \"green\" } };\nconst widths = [\n\t10,\n\tnull,\n\t20\n];\nconst more = [...widths, 30];\nconst bad = [...unknown];\nenum Level {\n\tLow = 1,\n\tHigh,\n\tName = \"n\",\n\tComputed = Math.max(1, 2),\n\tLast = 9\n}\ndeclare enum Ambient {\n\tX = 1\n}\nconst sx = { color: \"red\" };\nexport const a = \"a-b a-c\";\nexport const b = \"a-b a-d a-e a-f\";\nexport const c = \"a-b a-c a-g\";\nexport const d = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--d a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const e = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--e a-b a-c a-e\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const f = (Comp, handler) => ;\nexport const g =
;\nexport const h = \"a-b a-c\";\nexport const i = __devupForwardRef((__devupRefProps, __devupRef) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--i a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const j = __devupForwardRef((__devupRefProps, __devupRef) => ((__devupProps) => (({ style, className, as: DevupAs = \"div\", forwardedAs, ...rest }) => __devupDom)(rest)).filter(([k]) => k[0] !== \"$\"))} as={forwardedAs} className={[\"a--j a-b a-c\", className].filter(Boolean).join(\" \")} style={style} />)({\n\t...__devupProps,\n\t...{ role: \"button\" }\n}))({\n\t...__devupRefProps,\n\tref: __devupRef\n}));\nexport const k = { \"x\": \"a-s\" };\n;\nexport const l = \"a-t\";\nexport const m = \"a-u a-v a-w a-x\";\n", }, ) diff --git a/libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap b/libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap index ccd562a6b..324e79eca 100644 --- a/libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap +++ b/libs/extractor/src/snapshots/extractor__tests__devup_props_optional_chaining.snap @@ -8,7 +8,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "map?.k", + identifier: "__devupValue0", selector: None, style_order: None, }, @@ -17,11 +17,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "color", level: 0, - identifier: "map?.[k]", + identifier: "__devupValue1", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/react/devup-ui.css\";\nconst dotted =
;\nconst computed =
;\n", + code: "import \"@devup-ui/react/devup-ui.css\";\nconst dotted = ((__devupValue0) =>
)(map?.k);\nconst computed = ((__devupValue1) =>
)(map?.[k]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap b/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap index 2a72f6b7e..dba9b9edf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap +++ b/libs/extractor/src/snapshots/extractor__tests__dynamic_as_elements.snap @@ -29,7 +29,7 @@ expression: outputs ), ( "{items.map((i) => )}", - "{items.map((i) => i.href ? : )};", + "{items.map((i) => ((__devupValue0, DevupAs0) => )(i.id, (i.href ? \"a\" : Link) || \"div\"))};", ), ( "", @@ -73,7 +73,7 @@ expression: outputs ), ( "", - "((DevupAs0, __devupSpread0) => )([\"div\", \"a\"][i] || \"div\", register(\"x\"));", + "((DevupAs0, __devupSpread0) => )([\"div\", \"a\"][i] || \"div\", {\n\t__proto__: null,\n\t...register(\"x\")\n});", ), ( "", diff --git a/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap b/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap index 55ef30419..40065dc0f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap +++ b/libs/extractor/src/snapshots/extractor__tests__dynamic_value_with_important.snap @@ -8,12 +8,12 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "`${color}`", + identifier: "`${__devupValue0}`", selector: None, style_order: None, important: true, }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet color = \"red\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet color = \"red\";\n((__devupValue0) =>
)(`${color}`);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap b/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap index 80df0a778..18c8098b3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap +++ b/libs/extractor/src/snapshots/extractor__tests__enum_style_prop.snap @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "colors[variant]", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet variant = \"primary\";\nlet colors = {\n\tprimary: \"blue\",\n\tsecondary: \"red\"\n};\n
;\n", + code: "import \"@devup-ui/core/devup-ui-0.css\";\nlet variant = \"primary\";\nlet colors = {\n\tprimary: \"blue\",\n\tsecondary: \"red\"\n};\n((__devupValue0) =>
)(colors[variant]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap index 68b4845ca..f5c62339e 100644 --- a/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap +++ b/libs/extractor/src/snapshots/extractor__tests__evaluate_build_time_values.snap @@ -207,7 +207,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "color", level: 0, - identifier: "darken(.4,`#336699`)", + identifier: "__devupValue0", selector: None, style_order: None, }, @@ -229,5 +229,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { PRIMARY, SPACE } from \"./color\";\nimport { card } from \"./styles\";\nimport { BEFORE } from \"./fails\";\nexport const SIZE = 4;\nexport default function twice(n: number) {\n\treturn n * 2;\n}\nenum Level {\n\tLow = 1,\n\tHigh = twice(2)\n}\nfunction darken(amount: number, color: string): string {\n\treturn color === PRIMARY ? `darker(${amount})` : color;\n}\nconst LIGHT = darken(.2, PRIMARY);\nconst color = darken(.3, PRIMARY);\nconst make = (size: number) => ({\n\tp: size,\n\t_hover: { color: LIGHT }\n});\nconst label = (text: string) => text.toUpperCase().padStart(4, \"-\");\nexport const a = \"a-n a-o a-p a-q a-e a-r\";\nexport const b = \"a-s\";\n;\nconst styles = { \"base\": \"a-t\" };\nexport const c =
;\nexport const d = \"a-u a-v a-w a-x a-y a-i\";\nexport const e = (dark: boolean) => `a-_ ${dark ? \"a-t\" : \"a-j\"} ${dark ? \"a-z\" : \"\"}`;\nexport const f = \"a-aa\";\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nimport * as stylex from \"@stylexjs/stylex\";\nimport { PRIMARY, SPACE } from \"./color\";\nimport { card } from \"./styles\";\nimport { BEFORE } from \"./fails\";\nexport const SIZE = 4;\nexport default function twice(n: number) {\n\treturn n * 2;\n}\nenum Level {\n\tLow = 1,\n\tHigh = twice(2)\n}\nfunction darken(amount: number, color: string): string {\n\treturn color === PRIMARY ? `darker(${amount})` : color;\n}\nconst LIGHT = darken(.2, PRIMARY);\nconst color = darken(.3, PRIMARY);\nconst make = (size: number) => ({\n\tp: size,\n\t_hover: { color: LIGHT }\n});\nconst label = (text: string) => text.toUpperCase().padStart(4, \"-\");\nexport const a = \"a-n a-o a-p a-q a-e a-r\";\nexport const b = \"a-s\";\n;\nconst styles = { \"base\": \"a-t\" };\nexport const c = ((__devupValue0, __devupSpread0) =>
)(darken(.4, \"#336699\"), {\n\t__proto__: null,\n\t...{ className: \"a-t\" }\n});\nexport const d = \"a-u a-v a-w a-x a-y a-i\";\nexport const e = (dark: boolean) => `a-_ ${dark ? \"a-t\" : \"a-j\"} ${dark ? \"a-z\" : \"\"}`;\nexport const f = \"a-aa\";\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap b/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap index 7add116c6..bd642c21f 100644 --- a/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap +++ b/libs/extractor/src/snapshots/extractor__tests__every_runtime_value_an_element_holds.snap @@ -18,7 +18,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "new X", + identifier: "__devupValue3", selector: None, style_order: None, }, @@ -27,7 +27,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "color", level: 0, - identifier: "tag`x`", + identifier: "__devupValue4", selector: None, style_order: None, }, @@ -36,7 +36,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "height", level: 0, - identifier: "(f(),2)", + identifier: "__devupValue2", selector: None, style_order: None, }, @@ -45,7 +45,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "margin", level: 0, - identifier: "f.n++", + identifier: "__devupValue5", selector: None, style_order: None, }, @@ -54,7 +54,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "padding", level: 0, - identifier: "f.v=3", + identifier: "__devupValue6", selector: None, style_order: None, }, @@ -63,7 +63,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "width", level: 0, - identifier: "await f()", + identifier: "__devupValue0", selector: None, style_order: None, }, @@ -72,7 +72,7 @@ ToBTreeSet { ExtractDynamicStyle { property: "width", level: 0, - identifier: "this.#w", + identifier: "__devupValue1", selector: None, style_order: None, }, @@ -87,5 +87,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/react/devup-ui-0.css\";\nclass A {\n\t#w = 1;\n\trender(f) {\n\t\treturn ((__devupSpread0) =>
)(f());\n\t}\n}\nexport const a = async (f, X, tag) =>
;\n", + code: "import \"@devup-ui/react/devup-ui-0.css\";\nclass A {\n\t#w = 1;\n\trender(f) {\n\t\treturn ((__devupValue0, __devupSpread0, __devupSpread1) =>
)(this.#w, {\n\t\t\t__proto__: null,\n\t\t\t...this.#w\n\t\t}, {\n\t\t\t__proto__: null,\n\t\t\t...f()\n\t\t});\n\t}\n}\nexport const a = async (f, X, tag) => ((__devupValue1, __devupValue2, __devupValue3, __devupValue4, __devupValue5, __devupValue6) =>
)(await f(), (f(), 2), new X(), tag`x`, f.n++, f.v = 3);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-7.snap b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-7.snap index f84f36001..ea990e01d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-7.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-7.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "margin", level: 0, - identifier: "`${a}px`", + identifier: "__devupBranch0[`1`]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupBranch0) =>
)(a === b ? [true, `${a}px`] : [false]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-8.snap b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-8.snap index 078d3806c..e0d90cfdf 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-8.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-8.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "margin", level: 0, - identifier: "`${b}px`", + identifier: "__devupBranch0[`1`]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupBranch0) =>
)(a === b ? [true] : [false, `${b}px`]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-9.snap b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-9.snap index 92d6188ce..9907653a6 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-9.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props-9.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "margin", level: 0, - identifier: "`${b}px`", + identifier: "__devupBranch0[`1`]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupBranch0) =>
)(a === b ? [true, `${b}px`] : [false]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props_with_class_name-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props_with_class_name-2.snap index f006fc2a5..84463a440 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props_with_class_name-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_conditional_style_props_with_class_name-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.jsx\",\nr#\"import {Flex} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -37,5 +37,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupChoice0) =>
)([[\"0\"], [\"1\"]][a]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-2.snap index f31f8aeec..7fb2b1349 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-4.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-4.snap index 678bea7c0..9d6da4d75 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case-4.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case.snap index ad9a3934d..093d10b8d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_logical_case.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -14,5 +14,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-2.snap index 1fa4869d7..14cb278d3 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-2.snap @@ -1,6 +1,5 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 969 expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { @@ -9,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "left+right", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(left + right);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-4.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-4.snap index 9d403a555..79bfcb98d 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-4.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-4.snap @@ -1,6 +1,5 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 995 expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { @@ -9,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "data.buttonBgColor", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(data.buttonBgColor);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-5.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-5.snap index f75bb1c26..d722793bb 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-5.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props-5.snap @@ -1,6 +1,5 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 1008 expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { @@ -9,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "data.a.b.buttonBgColor", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(data.a.b.buttonBgColor);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-2.snap index d65910a96..1408e5b65 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-2.snap @@ -1,6 +1,5 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 12621 expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { @@ -9,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "color", level: 0, - identifier: "theme.color", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(theme.color);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-3.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-3.snap index 9f84fef12..4333e13c2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-3.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm-3.snap @@ -1,6 +1,5 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 12635 expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { @@ -9,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "width", level: 0, - identifier: "getWidth()", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(getWidth());\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm.snap index 681f4a2cb..779d44026 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_binary_member_call_arm.snap @@ -1,6 +1,5 @@ --- source: libs/extractor/src/lib.rs -assertion_line: 12607 expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#, ExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { @@ -9,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "background", level: 0, - identifier: "a+b", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(a + b);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_with_type-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_with_type-2.snap index 87cf1b738..0767ca207 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_with_type-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_dynamic_style_props_with_type-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "padding", level: 0, - identifier: "data[d as A]", + identifier: "__devupValue0", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) =>
)(data[d as A] as B);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_responsive_conditional_style_props_with_class_name.snap b/libs/extractor/src/snapshots/extractor__tests__extract_responsive_conditional_style_props_with_class_name.snap index c9197f7d7..0415e94ba 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_responsive_conditional_style_props_with_class_name.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_responsive_conditional_style_props_with_class_name.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n w ? \"4px\" : \"8px\") : \"3px\"]} className={\"exists\"} />;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n w ? \"4px\" : \"8px\") : \"3px\"]} className={\"exists\"} />;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -35,5 +35,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
w ? \"a\" : \"b\" : \"c\"}`} />;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupBranch0) =>
)(a === b ? [true, q > w] : [false]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_same_dynamic_value_conditional_style_props-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_same_dynamic_value_conditional_style_props-2.snap index 4ccf63ff8..0157ceb5b 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_same_dynamic_value_conditional_style_props-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_same_dynamic_value_conditional_style_props-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box } from \"@devup-ui/core\";\n;\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -8,11 +8,11 @@ ToBTreeSet { ExtractDynamicStyle { property: "margin", level: 0, - identifier: "`${a}`", + identifier: "__devupBranch0[`1`]", selector: None, style_order: None, }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupBranch0) =>
)(a === b ? [true, `${a}`] : [false, `${a}`]);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap b/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap index 7abad9202..9d57becda 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_selector-2.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Center} from '@devup-ui/core'\n \n {children}\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr\"import {Center} from '@devup-ui/core'\n \n {children}\n \n \",\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -97,5 +97,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n
\n {children}\n
;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupSpread0) =>
\n {children}\n
)({\n\t__proto__: null,\n\t...props\n});\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap index 93a01d85e..7e7186d17 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-6.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Image} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import {Image} from '@devup-ui/core'\n \n \"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -25,5 +25,5 @@ ToBTreeSet { }, ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\n\"Next.js;\n", + code: "import \"@devup-ui/core/devup-ui.css\";\n((__devupValue0) => \"Next.js)(styles.logo);\n", } diff --git a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap index 899efdf91..f453b8d87 100644 --- a/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap +++ b/libs/extractor/src/snapshots/extractor__tests__extract_style_props_with_class_name-9.snap @@ -1,6 +1,6 @@ --- source: libs/extractor/src/lib.rs -expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core'\nimport clsx from 'clsx'\n\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" +expression: "ToBTreeSet::from(extract(\"test.tsx\",\nr#\"import { Box, Button as DevupButton, Center, css } from '@devup-ui/core'\nimport clsx from 'clsx'\n\n\n\"#,\nExtractOption\n{\n package: \"@devup-ui/core\".to_string(), css_dir:\n \"@devup-ui/core\".to_string(), single_css: true, import_main_css: false,\n import_aliases: HashMap::new()\n}).unwrap())" --- ToBTreeSet { styles: { @@ -21,5 +21,5 @@ ToBTreeSet { "buttonS", ), }, - code: "import \"@devup-ui/core/devup-ui.css\";\nimport clsx from \"clsx\";\n