From c647ab5ff98185d495cc1337f074a5fcda1841c1 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 00:13:45 +0900 Subject: [PATCH 01/13] fix(extractor): reject whole Devup namespace runtime escapes Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/barrel.rs | 35 ++++- libs/extractor/src/barrel/aliases.rs | 21 +-- .../extractor/src/barrel/namespace_aliases.rs | 58 ++++++++ .../src/barrel/namespace_escape_tests.rs | 133 ++++++++++++++++++ libs/extractor/src/barrel/tests.rs | 19 +-- 5 files changed, 241 insertions(+), 25 deletions(-) create mode 100644 libs/extractor/src/barrel/namespace_aliases.rs create mode 100644 libs/extractor/src/barrel/namespace_escape_tests.rs 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("
Date: Mon, 5 Oct 2026 00:21:33 +0900 Subject: [PATCH 02/13] fix(extractor): preserve exact lexical values and binding-aware require Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- libs/extractor/src/build_time_values.rs | 8 +- .../src/build_time_values/exact_math.rs | 50 +++ .../src/build_time_values/w22_tests.rs | 43 +++ libs/extractor/src/imported_constants.rs | 361 ++++++++++++------ .../imported_constants/exact_edge_tests.rs | 57 +++ .../imported_constants/exact_math_tests.rs | 69 ++++ .../src/imported_constants/exact_tests.rs | 209 ++++++++++ .../src/imported_constants/initialization.rs | 225 +++++++++++ .../src/imported_constants/lexical.rs | 52 +++ .../numeric_semantics_tests.rs | 84 ++++ .../src/imported_constants/require_tests.rs | 98 +++++ .../src/imported_constants/tdz_tests.rs | 170 +++++++++ libs/extractor/src/lib.rs | 7 + 13 files changed, 1320 insertions(+), 113 deletions(-) create mode 100644 libs/extractor/src/build_time_values/exact_math.rs create mode 100644 libs/extractor/src/build_time_values/w22_tests.rs create mode 100644 libs/extractor/src/imported_constants/exact_edge_tests.rs create mode 100644 libs/extractor/src/imported_constants/exact_math_tests.rs create mode 100644 libs/extractor/src/imported_constants/exact_tests.rs create mode 100644 libs/extractor/src/imported_constants/initialization.rs create mode 100644 libs/extractor/src/imported_constants/lexical.rs create mode 100644 libs/extractor/src/imported_constants/numeric_semantics_tests.rs create mode 100644 libs/extractor/src/imported_constants/require_tests.rs create mode 100644 libs/extractor/src/imported_constants/tdz_tests.rs diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 38a55b80b..64d93beaf 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -111,10 +111,10 @@ const GLOBALS: [&str; 18] = [ /// The members of `Math` every engine gives exactly; the others are /// approximations that may differ in their last digits -const EXACT_MATH: [&str; 20] = [ - "abs", "ceil", "floor", "round", "trunc", "sign", "max", "min", "sqrt", "fround", "imul", - "clz32", "PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2", -]; +pub(crate) mod exact_math; +#[cfg(test)] +mod w22_tests; +use exact_math::EXACT_MATH; /// Members giving what the locale, the Unicode data of the engine or chance /// make them, and `toString`, which engines only approximate with a radix: diff --git a/libs/extractor/src/build_time_values/exact_math.rs b/libs/extractor/src/build_time_values/exact_math.rs new file mode 100644 index 000000000..7605d8409 --- /dev/null +++ b/libs/extractor/src/build_time_values/exact_math.rs @@ -0,0 +1,50 @@ +use boa_engine::{Context, Source}; + +/// Math members whose results the local safety gate permits across engines. +pub(crate) const EXACT_MATH: [&str; 20] = [ + "abs", "ceil", "floor", "round", "trunc", "sign", "max", "min", "sqrt", "fround", "imul", + "clz32", "PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2", +]; + +pub(crate) enum Operand<'a> { + 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/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/imported_constants.rs b/libs/extractor/src/imported_constants.rs index 661669134..e7cf7b058 100644 --- a/libs/extractor/src/imported_constants.rs +++ b/libs/extractor/src/imported_constants.rs @@ -31,6 +31,11 @@ use crate::extractor::extract_style_from_expression::{ use crate::stylex::StylexFunction; use crate::{ExtractOption, ExtractStyleValue, ModuleResolver}; +mod initialization; +mod lexical; +#[cfg(test)] +mod require_tests; + #[derive(Clone, Debug)] enum Constant { String(String), @@ -79,7 +84,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()), @@ -159,6 +168,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, @@ -425,13 +435,35 @@ 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 initialization = initialization::Initialization::new(program, scoping); + let declarations = lexical::declarations(ast_builder, program, scoping); + 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 mut inlined = Inlined { + errors: initialization.errors(&read.references, scoping), + ..Inlined::default() + }; + if !inlined.errors.is_empty() { + return inlined; + } let mut modules = Modules { resolver, option, @@ -439,7 +471,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( @@ -511,7 +542,10 @@ fn inline_in<'a>( // Constants are only worth reading when a style reads a name that may // hold one let reads_math = read.names.contains("Math") && !scope.binds("Math"); - if !reads_math && !read.names.iter().any(|name| scope.binds(name)) { + if !reads_math + && read.symbols.is_empty() + && !read.names.iter().any(|name| scope.binds(name)) + { return Inlined::default(); } for name in &read.names { @@ -568,10 +602,54 @@ 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.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, + objects: false, + styles: false, + px: false, + class_names: Vec::new(), + }; + let resolved: Vec<_> = pending + .iter() + .filter_map(|(symbol, init)| { + let value = 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 + ) + .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, @@ -748,6 +826,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 @@ -765,8 +845,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()); + } } } @@ -1142,6 +1227,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, + )>, imports: FxHashMap, style_imports: FxHashSet, /// Style APIs besides the imports, which never run what they are given @@ -1167,6 +1257,8 @@ impl<'p, 'a> ModuleScope<'p, 'a> { source, locals: FxHashMap::default(), declarations: FxHashMap::default(), + enums: FxHashMap::default(), + enum_members: None, imports: FxHashMap::default(), style_imports: FxHashSet::default(), style_names: FxHashSet::default(), @@ -1327,8 +1419,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 { @@ -1336,37 +1433,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<'_>) { @@ -1397,7 +1503,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(); @@ -1456,6 +1568,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); + } // Taken out while it is evaluated, so a constant reading itself stops if let Some(init) = self.declarations.remove(name) && let Some(constant) = self.evaluate(modules, init) @@ -1496,16 +1613,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 @@ -1652,9 +1773,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) => { @@ -1676,9 +1802,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()) } @@ -1686,21 +1827,20 @@ 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(Constant::Style(self.css_styles(modules, call))) + if self.is_style_api(modules, &call.callee) { + return Some(Constant::Style(self.css_styles(modules, call))); } - _ => self.evaluate_stylex(modules, call), - }, + self.evaluate_stylex(modules, call) + } Expression::TaggedTemplateExpression(tagged) if self.is_style_api(modules, &tagged.tag) => { @@ -1830,61 +1970,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 @@ -1925,6 +2054,7 @@ fn fold_binary(operator: BinaryOperator, left: &Constant, right: &Constant) -> O 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>, @@ -1945,6 +2075,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) => { @@ -1955,30 +2088,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() @@ -1986,16 +2112,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 => { @@ -2005,6 +2126,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, } } @@ -2015,6 +2138,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), }) } @@ -2146,7 +2279,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, @@ -2322,7 +2455,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..da39d556b --- /dev/null +++ b/libs/extractor/src/imported_constants/exact_tests.rs @@ -0,0 +1,209 @@ +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}")); + assert_eq!( + static_values(&output), + vec!["7px".to_string(), "7".to_string()] + ); + 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..5fb07a5f7 --- /dev/null +++ b/libs/extractor/src/imported_constants/initialization.rs @@ -0,0 +1,225 @@ +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, +} + +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 { + initialized: class.span.end, + // Class self-bindings initialize after keys/extends, before static values. + 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(), + frame: self.frame, + temporal: true, + }, + ); + } + 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..8554d301e --- /dev/null +++ b/libs/extractor/src/imported_constants/lexical.rs @@ -0,0 +1,52 @@ +use oxc_allocator::{CloneIn, GetAllocator}; +use oxc_ast::ast::{Expression, Program, VariableDeclaration, VariableDeclarationKind}; +use oxc_ast::builder::AstBuilder; +use oxc_ast_visit::{Visit, walk}; +use oxc_semantic::Scoping; +use oxc_syntax::symbol::SymbolId; +use rustc_hash::FxHashMap; + +/// Const initializers keyed by lexical identity, excluding written bindings. +pub(super) fn declarations<'a>( + builder: &AstBuilder<'a>, + program: &Program<'a>, + scoping: &Scoping, +) -> FxHashMap> { + let mut collector = Collector { + builder, + scoping, + found: FxHashMap::default(), + }; + collector.visit_program(program); + collector.found +} + +struct Collector<'s, 'a> { + builder: &'s AstBuilder<'a>, + scoping: &'s Scoping, + found: FxHashMap>, +} + +impl<'a> Visit<'a> for Collector<'_, 'a> { + 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) + .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/tdz_tests.rs b/libs/extractor/src/imported_constants/tdz_tests.rs new file mode 100644 index 000000000..adf4b1b0b --- /dev/null +++ b/libs/extractor/src/imported_constants/tdz_tests.rs @@ -0,0 +1,170 @@ +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, + ); + (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/lib.rs b/libs/extractor/src/lib.rs index d3c60b13a..2fe3ae844 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -467,6 +467,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); let mut visitor = DevupVisitor::new( &allocator, From d257aac6d6b700aecf2448d6ad08c6c73b4328da Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 5 Oct 2026 00:36:38 +0900 Subject: [PATCH 03/13] fix(extractor): preserve JavaScript props evaluation during lowering Refs #695 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../extract_style/extract_dynamic_style.rs | 94 ++- .../extractor/extract_style_from_styled.rs | 9 +- libs/extractor/src/gen_class_name.rs | 6 +- libs/extractor/src/gen_style.rs | 32 +- libs/extractor/src/jsx_semantics_tests.rs | 139 +++++ .../src/jsx_semantics_tests/binding_edges.rs | 184 ++++++ .../src/jsx_semantics_tests/calls.rs | 201 ++++++ .../capture_preservation.rs | 43 ++ .../src/jsx_semantics_tests/captures.rs | 66 ++ .../src/jsx_semantics_tests/create_element.rs | 145 +++++ .../src/jsx_semantics_tests/css_prop.rs | 70 +++ .../src/jsx_semantics_tests/errors.rs | 81 +++ .../jsx_semantics_tests/evaluation_edges.rs | 89 +++ .../src/jsx_semantics_tests/forwarded.rs | 82 +++ .../src/jsx_semantics_tests/gate_edges.rs | 197 ++++++ .../src/jsx_semantics_tests/ordered.rs | 152 +++++ .../src/jsx_semantics_tests/repeated.rs | 198 ++++++ .../src/jsx_semantics_tests/slots.rs | 259 ++++++++ .../src/jsx_semantics_tests/style_order.rs | 78 +++ .../src/jsx_semantics_tests/styled.rs | 155 +++++ .../src/jsx_semantics_tests/type_imports.rs | 75 +++ .../src/jsx_semantics_tests/whole.rs | 56 ++ .../src/legacy_semantics_contract_tests.rs | 170 +++++ libs/extractor/src/lib.rs | 66 +- libs/extractor/src/prop_modify_utils.rs | 23 +- libs/extractor/src/scope/lookup.rs | 8 +- libs/extractor/src/scope/required.rs | 2 +- libs/extractor/src/scope/tests/diagnostics.rs | 3 +- libs/extractor/src/scope/visits.rs | 15 +- ...ractor__tests__apply_var_typography-3.snap | 4 +- ...ractor__tests__apply_var_typography-5.snap | 2 +- ...ractor__tests__apply_var_typography-6.snap | 2 +- .../extractor__tests__backtick_prop-2.snap | 6 +- .../extractor__tests__changed_constants.snap | 12 +- ...ts__compose_rules_the_module_computes.snap | 2 +- ...what_elements_and_styled_objects_take.snap | 2 +- .../extractor__tests__conditional_styles.snap | 14 +- ...ts__constant_objects_arrays_and_enums.snap | 4 +- ..._tests__devup_props_optional_chaining.snap | 6 +- ...extractor__tests__dynamic_as_elements.snap | 4 +- ...__tests__dynamic_value_with_important.snap | 4 +- .../extractor__tests__enum_style_prop.snap | 4 +- ...or__tests__evaluate_build_time_values.snap | 4 +- ..._every_runtime_value_an_element_holds.snap | 16 +- ...ts__extract_conditional_style_props-7.snap | 6 +- ...ts__extract_conditional_style_props-8.snap | 6 +- ...ts__extract_conditional_style_props-9.snap | 6 +- ...itional_style_props_with_class_name-2.snap | 4 +- ...tests__extract_dynamic_logical_case-2.snap | 4 +- ...tests__extract_dynamic_logical_case-4.snap | 4 +- ...__tests__extract_dynamic_logical_case.snap | 4 +- ..._tests__extract_dynamic_style_props-2.snap | 5 +- ..._tests__extract_dynamic_style_props-4.snap | 5 +- ..._tests__extract_dynamic_style_props-5.snap | 5 +- ..._style_props_binary_member_call_arm-2.snap | 5 +- ..._style_props_binary_member_call_arm-3.snap | 5 +- ...ic_style_props_binary_member_call_arm.snap | 5 +- ...tract_dynamic_style_props_with_type-2.snap | 6 +- ...nditional_style_props_with_class_name.snap | 4 +- ...namic_value_conditional_style_props-2.snap | 6 +- .../extractor__tests__extract_selector-2.snap | 4 +- ...extract_style_props_with_class_name-6.snap | 4 +- ...extract_style_props_with_class_name-9.snap | 4 +- ...tor__tests__inline_imported_constants.snap | 341 +++++----- ...ractor__tests__inline_local_constants.snap | 6 +- ...actor__tests__member_expression_style.snap | 4 +- ...member_expression_with_dynamic_values.snap | 13 +- .../extractor__tests__negative_props-2.snap | 6 +- .../extractor__tests__negative_props-3.snap | 6 +- .../extractor__tests__negative_props-4.snap | 6 +- ...rops_direct_array_responsive_select-2.snap | 4 +- ..._props_direct_array_responsive_select.snap | 4 +- ...r__tests__props_direct_array_select-2.snap | 4 +- ...r__tests__props_direct_array_select-3.snap | 4 +- ...r__tests__props_direct_array_select-4.snap | 6 +- ...r__tests__props_direct_array_select-5.snap | 4 +- ...r__tests__props_direct_array_select-6.snap | 4 +- ...r__tests__props_direct_array_select-7.snap | 24 +- ...tor__tests__props_direct_array_select.snap | 14 +- ...props_direct_hybrid_responsive_select.snap | 19 +- ...ops_direct_object_responsive_select-2.snap | 4 +- ...props_direct_object_responsive_select.snap | 4 +- ...__tests__props_direct_object_select-2.snap | 4 +- ...__tests__props_direct_object_select-3.snap | 4 +- ...__tests__props_direct_object_select-4.snap | 4 +- ...or__tests__props_direct_object_select.snap | 14 +- ...ct_variable_array_responsive_select-2.snap | 10 +- ...rect_variable_array_responsive_select.snap | 37 +- ...ect_variable_object_responsive_select.snap | 37 +- ...props_direct_variable_object_select-2.snap | 6 +- ...__props_direct_variable_object_select.snap | 4 +- .../extractor__tests__props_direct_wrong.snap | 4 +- ...ractor__tests__props_multi_expression.snap | 4 +- ...ts__props_wrong_direct_array_select-2.snap | 24 +- ...ts__props_wrong_direct_array_select-3.snap | 24 +- ...ts__props_wrong_direct_array_select-4.snap | 24 +- ...ts__props_wrong_direct_array_select-5.snap | 17 +- ...ts__props_wrong_direct_array_select-6.snap | 4 +- ...ts__props_wrong_direct_array_select-7.snap | 4 +- ...ts__props_wrong_direct_array_select-8.snap | 14 +- ...ests__props_wrong_direct_array_select.snap | 4 +- ...s__props_wrong_direct_object_select-2.snap | 24 +- ...s__props_wrong_direct_object_select-3.snap | 24 +- ...s__props_wrong_direct_object_select-4.snap | 24 +- ...s__props_wrong_direct_object_select-5.snap | 4 +- ...sts__props_wrong_direct_object_select.snap | 4 +- ...ctor__tests__read_once_in_every_place.snap | 2 +- .../extractor__tests__remove_semicolon-4.snap | 6 +- .../extractor__tests__remove_semicolon-5.snap | 6 +- .../extractor__tests__rest_props-2.snap | 4 +- .../extractor__tests__rest_props.snap | 13 +- .../extractor__tests__spread_props.snap | 2 +- ...tractor__tests__spreads_are_read_once.snap | 2 +- ...tor__tests__style_order_conditional-2.snap | 54 +- ...tor__tests__style_order_conditional-3.snap | 30 +- ...s__style_order_conditional_coverage-3.snap | 2 +- ...s__style_order_conditional_coverage-6.snap | 20 +- ...ts__style_order_coverage_additional-4.snap | 2 +- ...__tests__style_results_read_as_values.snap | 21 +- .../extractor__tests__style_variables-11.snap | 4 +- .../extractor__tests__style_variables-9.snap | 4 +- ...__tests__styles_the_build_cannot_read.snap | 6 +- ...actor__tests__support_transpile_cjs-7.snap | 34 +- ...actor__tests__support_transpile_mjs-5.snap | 4 +- ...literal_with_function_call_expression.snap | 4 +- ...plate_literal_with_logical_expression.snap | 4 +- ..._literal_with_nested_template_literal.snap | 4 +- ...literal_with_parenthesized_expression.snap | 4 +- ...ind_template_literal_with_style_order.snap | 4 +- ...late_literal_with_variable_expression.snap | 4 +- ...ind_with_conditional_template_literal.snap | 4 +- ...__tests__values_known_only_at_runtime.snap | 14 +- libs/extractor/src/utils.rs | 123 ++-- libs/extractor/src/utils/w22_tests.rs | 56 ++ libs/extractor/src/visit.rs | 580 ++++++++++-------- libs/extractor/src/visit/branch_capture.rs | 144 +++++ libs/extractor/src/visit/call_order.rs | 239 ++++++++ libs/extractor/src/visit/call_target.rs | 135 ++++ libs/extractor/src/visit/capture.rs | 258 ++++++++ libs/extractor/src/visit/class_capture.rs | 70 +++ libs/extractor/src/visit/element_calls.rs | 66 ++ libs/extractor/src/visit/imports.rs | 33 + libs/extractor/src/visit/jsx_order.rs | 281 +++++++++ libs/extractor/src/visit/names.rs | 46 ++ libs/extractor/src/visit/order.rs | 288 +++++++++ libs/extractor/src/visit/order_tests.rs | 40 ++ libs/extractor/src/visit/selected_capture.rs | 101 +++ libs/extractor/src/visit/spread_slots.rs | 209 +++++++ libs/extractor/src/visit/spread_slots/take.rs | 102 +++ libs/extractor/src/visit/style_order.rs | 97 +++ libs/sheet/src/lib.rs | 20 + 151 files changed, 5847 insertions(+), 975 deletions(-) create mode 100644 libs/extractor/src/jsx_semantics_tests.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/binding_edges.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/calls.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/capture_preservation.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/captures.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/create_element.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/css_prop.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/errors.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/evaluation_edges.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/forwarded.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/gate_edges.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/ordered.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/repeated.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/slots.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/style_order.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/styled.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/type_imports.rs create mode 100644 libs/extractor/src/jsx_semantics_tests/whole.rs create mode 100644 libs/extractor/src/legacy_semantics_contract_tests.rs create mode 100644 libs/extractor/src/utils/w22_tests.rs create mode 100644 libs/extractor/src/visit/branch_capture.rs create mode 100644 libs/extractor/src/visit/call_order.rs create mode 100644 libs/extractor/src/visit/call_target.rs create mode 100644 libs/extractor/src/visit/capture.rs create mode 100644 libs/extractor/src/visit/class_capture.rs create mode 100644 libs/extractor/src/visit/element_calls.rs create mode 100644 libs/extractor/src/visit/imports.rs create mode 100644 libs/extractor/src/visit/jsx_order.rs create mode 100644 libs/extractor/src/visit/names.rs create mode 100644 libs/extractor/src/visit/order.rs create mode 100644 libs/extractor/src/visit/order_tests.rs create mode 100644 libs/extractor/src/visit/selected_capture.rs create mode 100644 libs/extractor/src/visit/spread_slots.rs create mode 100644 libs/extractor/src/visit/spread_slots/take.rs create mode 100644 libs/extractor/src/visit/style_order.rs diff --git a/libs/extractor/src/extract_style/extract_dynamic_style.rs b/libs/extractor/src/extract_style/extract_dynamic_style.rs index f09b03b07..73c7d8e71 100644 --- a/libs/extractor/src/extract_style/extract_dynamic_style.rs +++ b/libs/extractor/src/extract_style/extract_dynamic_style.rs @@ -5,7 +5,17 @@ use css::{ style_selector::{StyleSelector, optimize_selector}, }; -use crate::extract_style::{ExtractStyleProperty, style_property::StyleProperty}; +use crate::extract_style::{ + ExtractStyleProperty, extract_static_style::ExtractStaticStyle, style_property::StyleProperty, +}; + +/// The variable an element sets to override a style it writes before a spread +/// the build cannot read, and the value the style keeps while it is unset +#[derive(PartialEq, Clone, Eq, Hash, Ord, PartialOrd, Debug)] +struct Override { + variable: String, + fallback: String, +} #[derive(PartialEq, Clone, Eq, Hash, Ord, PartialOrd)] pub struct ExtractDynamicStyle { @@ -24,6 +34,9 @@ pub struct ExtractDynamicStyle { important: bool, pub(crate) layer: Option, + + /// Set when the style is a static value a runtime spread may override + overridable: Option, } impl Debug for ExtractDynamicStyle { @@ -40,6 +53,9 @@ impl Debug for ExtractDynamicStyle { if let Some(layer) = &self.layer { s.field("layer", layer); } + if let Some(overridable) = &self.overridable { + s.field("overridable", overridable); + } s.finish() } } @@ -99,6 +115,19 @@ fn runtime_code(identifier: &str) -> String { } } +/// `key` as the characters of a variable name, each other character by its code +fn escaped(key: &str) -> String { + key.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' { + c.to_string() + } else { + format!("_u{:04x}_", u32::from(c)) + } + }) + .collect() +} + impl ExtractDynamicStyle { /// create a new `ExtractDynamicStyle` pub fn new( @@ -118,9 +147,48 @@ impl ExtractDynamicStyle { style_order: None, important, layer: None, + overridable: None, } } + /// `style` as a stylesheet rule that gives way to `identifier`, the code + /// an element sets on a variable only the prop `key` it is written as + /// reads, so every breakpoint of that prop shares it and no other prop + /// setting the same property can reach it + pub fn overridable(style: &ExtractStaticStyle, identifier: &str, key: &str) -> Self { + let (fallback, important) = strip_important(style.value.clone()); + Self { + property: style.property.clone(), + level: style.level, + identifier: identifier.to_string(), + selector: None, + style_order: style.style_order, + important, + layer: style.layer.clone(), + overridable: Some(Override { + variable: sheet_to_variable_name( + &format!("{}-spread-{}", style.property, escaped(key)), + 0, + None, + ), + fallback, + }), + } + } + + /// Give way to what `read` reads over the value set now, as a spread + /// written after it replaces it: `read` is given that value + pub fn overridden_by(&mut self, read: impl FnOnce(&str) -> String) { + self.identifier = read(&self.identifier); + } + + /// The value `var()` falls back to while no override is set + pub fn fallback(&self) -> Option<&str> { + self.overridable + .as_ref() + .map(|overridable| overridable.fallback.as_str()) + } + pub const fn property(&self) -> &str { self.property.as_str() } @@ -153,19 +221,27 @@ impl ExtractDynamicStyle { impl ExtractStyleProperty for ExtractDynamicStyle { fn extract(&self, filename: Option<&str>) -> StyleProperty { let selector = super::class_selector(self.selector.as_ref(), self.layer()); + // What the rule falls back to is part of the class, as another + // fallback is another rule + let rule = self.overridable.as_ref().map(|overridable| { + let important = if self.important { " !important" } else { "" }; + format!( + "var({},{}){important}", + overridable.variable, overridable.fallback + ) + }); StyleProperty::Variable { class_name: sheet_to_classname( self.property.as_str(), self.level, - None, + rule.as_deref(), selector.as_deref(), self.style_order, filename, ), - variable_name: sheet_to_variable_name( - self.property.as_str(), - self.level, - selector.as_deref(), + variable_name: self.overridable.as_ref().map_or_else( + || sheet_to_variable_name(self.property.as_str(), self.level, selector.as_deref()), + |overridable| overridable.variable.clone(), ), identifier: self.identifier.clone(), } @@ -187,6 +263,12 @@ mod tests { assert!(!style.important()); } + #[test] + fn test_escaped_keeps_names_and_codes_every_other_character() { + assert_eq!(escaped("background-color"), "background-color"); + assert_eq!(escaped("a_b c"), "a_u005f_b_u0020_c"); + } + #[test] fn test_strip_important_plain() { let (id, important) = strip_important("color".to_string()); diff --git a/libs/extractor/src/extractor/extract_style_from_styled.rs b/libs/extractor/src/extractor/extract_style_from_styled.rs index 1571b5093..c7d0bf11f 100644 --- a/libs/extractor/src/extractor/extract_style_from_styled.rs +++ b/libs/extractor/src/extractor/extract_style_from_styled.rs @@ -99,13 +99,18 @@ fn combine_forward(inherited: Option<&Forward>, own: Option) -> Option< } 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" 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..079cacbf5 100644 --- a/libs/extractor/src/gen_style.rs +++ b/libs/extractor/src/gen_style.rs @@ -28,6 +28,22 @@ fn extend_reversed_styles<'a>( ); } +/// Keep one `'--name': value` entry for each variable a breakpoint-wise +/// override shares between classes +fn drop_repeated_variables(properties: &mut Vec>) { + let mut seen = FxHashSet::default(); + properties.retain(|property| { + if let ObjectPropertyKind::ObjectProperty(property) = property + && let Some(name) = property.key.name() + && name.starts_with("--") + && let Expression::Identifier(value) = &property.value + { + return seen.insert((name.into_owned(), value.name.to_string())); + } + true + }); +} + pub fn gen_styles<'a>( ast_builder: &AstBuilder<'a>, style_props: &[ExtractStyleProp<'a>], @@ -38,6 +54,7 @@ pub fn gen_styles<'a>( } let mut properties: Vec<_> = Vec::with_capacity(style_props.len()); extend_reversed_styles(ast_builder, &mut properties, style_props, filename); + drop_repeated_variables(&mut properties); if properties.is_empty() { return None; } @@ -60,7 +77,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 +383,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/jsx_semantics_tests.rs b/libs/extractor/src/jsx_semantics_tests.rs new file mode 100644 index 000000000..2504c8b7f --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests.rs @@ -0,0 +1,139 @@ +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 calls; +mod capture_preservation; +mod captures; +mod create_element; +mod css_prop; +mod errors; +mod evaluation_edges; +mod forwarded; +mod gate_edges; +mod ordered; +mod repeated; +mod slots; +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 { + match compile(source) { + Err(message) => message, + Ok(compiled) => panic!("the file compiles: {}", compiled.code), + } +} + +/// A style that gives way to a spread: its property, breakpoint, the code +/// setting the variable it reads, the variable and the value it keeps +struct Slot { + property: String, + level: u8, + identifier: String, + variable: String, + fallback: String, +} + +fn slots(output: &ExtractOutput) -> Vec { + let mut found: Vec<_> = output + .styles + .iter() + .filter_map(|style| match (style, style.extract(None)) { + ( + ExtractStyleValue::Dynamic(dynamic), + Some(StyleProperty::Variable { variable_name, .. }), + ) => Some(Slot { + property: dynamic.property().to_string(), + level: dynamic.level(), + identifier: captures::materialized(dynamic.identifier(), &output.code), + variable: variable_name, + fallback: dynamic.fallback()?.to_string(), + }), + _ => None, + }) + .collect(); + found.sort_by(|a, b| { + (&a.property, a.level, &a.fallback).cmp(&(&b.property, b.level, &b.fallback)) + }); + found +} + +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(), + ) +} + +/// What the code `identifier` of a slot reads once `setup` has bound the +/// props the spreads give: the value as JSON, or `undefined` when it sets none +fn read(identifier: &str, setup: &str) -> String { + run(&format!( + "{setup} const slot = {identifier}; return slot === undefined ? 'undefined' : JSON.stringify(slot);" + )) +} 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..738965300 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/binding_edges.rs @@ -0,0 +1,184 @@ +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!( + actual.element["props"]["style"] + .as_object() + .is_some_and(serde_json::Map::is_empty) + ); +} + +#[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/calls.rs b/libs/extractor/src/jsx_semantics_tests/calls.rs new file mode 100644 index 000000000..57e60a571 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/calls.rs @@ -0,0 +1,201 @@ +use super::*; +use serial_test::serial; + +const CREATE_ELEMENT: &str = "import { createElement } from 'react';\n"; + +#[test] +#[serial] +fn a_call_gives_way_to_a_spread_as_an_element_does() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ color: 'red', ...rest }});\nexport const b = (rest) => jsx(Box, {{ ...rest, color: 'red' }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "red"); + assert_eq!( + read(&found[0].identifier, "const rest = { color: 'blue' };"), + "\"blue\"" + ); + assert_eq!(read(&found[0].identifier, "const rest = {};"), "undefined"); + assert_eq!(static_styles(&output).len(), 1); +} + +#[test] +#[serial] +fn a_call_gives_the_last_own_key_of_repeated_spreads_and_the_final_explicit_style() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (first, last) => jsx(Box, {{ color: 'red', ...first, color: 'blue', ...last }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + let both = |first: &str, last: &str| format!("const first = {first}; const last = {last};"); + assert_eq!( + read(&found[0].identifier, &both("{ color: 'x' }", "{}")), + "undefined" + ); + assert_eq!( + read(&found[0].identifier, &both("{}", "{ color: undefined }")), + "undefined" + ); + assert_eq!( + read(&found[0].identifier, &both("{}", "{ color: 'y' }")), + "\"y\"" + ); +} + +#[test] +#[serial] +fn a_call_keeps_the_explicit_style_written_after_its_spread_without_a_slot() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ color: 'red', ...rest, color: 'blue' }});" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "blue".to_string())] + ); +} + +#[test] +#[serial] +fn a_call_replaces_an_alias_written_earlier_in_the_props() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ bg: 'red', background: 'blue', ...rest }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); +} + +#[test] +#[serial] +fn a_call_with_a_runtime_value_does_not_bring_it_back_over_a_later_own_undefined() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest, x) => jsx(Box, {{ p: x, ...rest }});" + )); + let identifier = output + .styles + .iter() + .find_map(|style| match style { + ExtractStyleValue::Dynamic(dynamic) => { + Some(captures::materialized(dynamic.identifier(), &output.code)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("a dynamic style: {}", output.code)); + + assert_eq!( + read(&identifier, "const x = 5; const rest = { p: undefined };"), + "undefined" + ); + assert_eq!(read(&identifier, "const x = 5; const rest = {};"), "5"); +} + +#[test] +#[serial] +fn a_call_gives_way_with_the_keys_of_a_literal_spread_before_an_unknown_one() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ ...{{ color: 'red' }}, ...rest }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "red"); +} + +#[test] +#[serial] +fn a_call_reads_its_spread_once() { + let output = output(&format!( + "{BOX}{JSX_RUNTIME}export const a = (f) => jsx(Box, {{ color: 'red', ...f() }});" + )); + + assert_eq!(output.code.matches("f()").count(), 1, "{}", output.code); + assert!(output.code.contains("__devupSpread0"), "{}", output.code); +} + +#[test] +#[serial] +fn a_call_with_a_selector_object_before_a_spread_is_an_error() { + let message = error(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ _hover: {{ bg: 'red' }}, ...rest }});" + )); + + assert!(message.contains("cannot use `_hover`"), "{message}"); +} + +#[test] +#[serial] +fn a_call_keeps_the_props_it_does_not_compile() { + 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 a_key_that_is_not_an_identifier_is_read_by_name_in_a_call() { + let rendered = code(&format!( + "{BOX}{JSX_RUNTIME}export const a = (rest) => jsx(Box, {{ 'background-color': 'red', ...rest }});" + )); + + assert!(rendered.contains("[\"background-color\"]"), "{rendered}"); +} + +#[test] +#[serial] +fn a_call_with_suspending_props_captures_them_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_props_give_way_to_a_spread_after_an_explicit_style() { + let output = output(&format!( + "{CREATE_ELEMENT}{BOX}export const a = (rest) => createElement(Box, {{ color: 'red', ...rest }});" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!( + read(&found[0].identifier, "const rest = { color: 'blue' };"), + "\"blue\"" + ); +} + +#[test] +#[serial] +fn create_element_gives_the_final_explicit_style_over_repeated_spreads() { + let output = output(&format!( + "{CREATE_ELEMENT}{BOX}export const a = (first, last) => createElement(Box, {{ color: 'red', ...first, color: 'blue', ...last }}, 'child');" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + assert!(output.code.contains("\"child\""), "{}", output.code); +} + +#[test] +#[serial] +fn create_element_with_props_held_in_a_value_gives_way_to_them() { + let output = output(&format!( + "{CREATE_ELEMENT}{BOX}export const a = (p) => createElement(Box, p, 'x');" + )); + + assert!(output.code.contains("...p"), "{}", output.code); + assert!(output.code.contains("?.className"), "{}", output.code); +} 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..f8013f0ca --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/capture_preservation.rs @@ -0,0 +1,43 @@ +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 a_computed_literal_selector_when_an_unknown_key_can_replace_it_is_located() { + let message = error(&format!( + "{BOX}{JSX_RUNTIME}export const a=(key)=>jsx(Box,{{['_hover']:{{bg:'blue'}},[key]:'red'}});" + )); + assert!(message.contains("cannot use `_hover`"), "{message}"); + assert!(message.starts_with("a.tsx:3:"), "{message}"); +} diff --git a/libs/extractor/src/jsx_semantics_tests/captures.rs b/libs/extractor/src/jsx_semantics_tests/captures.rs new file mode 100644 index 000000000..ce0306eb3 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/captures.rs @@ -0,0 +1,66 @@ +use oxc_allocator::Allocator; +use oxc_ast::ast::{CallExpression, Expression}; +use oxc_ast_visit::{Visit, walk}; +use oxc_parser::Parser; +use oxc_span::SourceType; + +#[derive(Default)] +struct Captures { + wrappers: Vec<(Vec, Vec)>, +} + +impl<'a> Visit<'a> for Captures { + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Expression::ArrowFunctionExpression(arrow) = + crate::utils::unwrap_syntax_only(&call.callee) + { + let names: Vec<_> = arrow + .params + .items + .iter() + .filter_map(|parameter| { + parameter + .pattern + .get_identifier_name() + .map(|name| name.to_string()) + }) + .collect(); + if names.iter().any(|name| { + name.starts_with("__devupSpread") + || name.starts_with("__devupValue") + || name.starts_with("__devupBranch") + }) { + let values = call + .arguments + .iter() + .map(crate::utils::readable_argument) + .collect(); + self.wrappers.push((names, values)); + } + } + walk::walk_call_expression(self, call); + } +} + +pub(super) fn materialized(identifier: &str, code: &str) -> String { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, code, SourceType::tsx()).parse(); + let mut captures = Captures::default(); + captures.visit_program(&parsed.program); + captures + .wrappers + .iter() + .rev() + .fold(identifier.to_string(), |expression, (names, values)| { + if names.iter().any(|name| expression.contains(name)) { + format!( + "(({}) => ({}))({})", + names.join(","), + expression, + values.join(",") + ) + } else { + expression + } + }) +} 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/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..bc120c15a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/errors.rs @@ -0,0 +1,81 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn a_selector_object_before_a_spread_is_a_located_error() { + let message = error(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert!( + message.starts_with("a.tsx:3:3: `` cannot use `_hover`"), + "{message}" + ); + assert!( + message.contains("write the spread before `_hover`"), + "{message}" + ); +} + +#[test] +#[serial] +fn a_selector_object_after_a_spread_keeps_its_rule() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!(static_styles(&output).len(), 1); +} + +#[test] +#[serial] +fn a_runtime_value_under_a_selector_before_a_spread_is_an_error() { + let message = error(&format!( + "{BOX}export const a = (rest, x) => ;" + )); + + assert!(message.contains("cannot use `_hover`"), "{message}"); +} + +#[test] +#[serial] +fn shapes_no_variable_can_follow_are_located_errors() { + for (attribute, key) in [ + ("typography=\"body\"", "typography"), + ("bg={['red', 'blue'][index]}", "bg"), + ("positioning={mode}", "positioning"), + ("selectors={{ '& > a': { color: 'red' } }}", "selectors"), + ] { + let message = error(&format!( + "{BOX}export const a = (rest, index, mode) => ;" + )); + + assert!( + message.contains(&format!("cannot use `{key}`")), + "{attribute}: {message}" + ); + } +} + +#[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..cdbf86320 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/forwarded.rs @@ -0,0 +1,82 @@ +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"a(null,{'className':'first','style':{'opacity':2}},{'className':undefined,'style':undefined})", + ); + assert!( + actual.element["props"]["className"] + .as_str() + .is_some_and(|class| !class.contains("first") && !class.contains("direct")) + ); + assert!( + actual.element["props"]["style"] + .as_object() + .is_some_and(|style| !style.contains_key("opacity")) + ); +} + +#[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..20ae1e5c3 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/gate_edges.rs @@ -0,0 +1,197 @@ +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_early_snapshot_would_hide_it_reports_the_original_key() { + let message = error(&format!( + "{BOX}export const a=(rest,key)=>;" + )); + assert!( + message.contains("_hover") && message.starts_with("a.tsx:2:"), + "{message}" + ); +} + +#[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_an_opaque_literal_is_snapshotted_is_located(#[case] rule: &str) { + let message = error(&format!( + "{BOX}export const a=(rest,key)=>;" + )); + assert!( + message.starts_with("a.tsx:2:") && message.contains(rule), + "{message}" + ); +} + +#[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..a44d88b8e --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/ordered.rs @@ -0,0 +1,152 @@ +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!( + slots(&output(&source)) + .iter() + .any(|slot| slot.fallback == "8px") + ); +} + +#[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!( + slots(&output(&source)) + .iter() + .any(|slot| slot.fallback == "8px") + ); +} 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..ef966db44 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/repeated.rs @@ -0,0 +1,198 @@ +use super::*; +use serial_test::serial; + +fn slot_of<'a>(found: &'a [Slot], property: &str) -> &'a Slot { + found + .iter() + .find(|slot| slot.property == property) + .unwrap_or_else(|| panic!("a slot for `{property}`")) +} + +#[test] +#[serial] +fn the_last_spread_having_the_key_wins_and_a_missing_key_keeps_the_earlier_one() { + let output = output(&format!( + "{BOX}export const a = (a, b) => ;" + )); + let found = slots(&output); + let color = &slot_of(&found, "color").identifier; + let padding = &slot_of(&found, "padding").identifier; + + assert_eq!(found.len(), 2); + let both = |a: &str, b: &str| format!("const a = {a}; const b = {b};"); + assert_eq!(read(color, &both("{ color: 'blue' }", "{}")), "\"blue\""); + assert_eq!( + read(color, &both("{ color: 'blue' }", "{ color: 'green' }")), + "\"green\"" + ); + assert_eq!(read(color, &both("{}", "{ color: 'green' }")), "\"green\""); + assert_eq!(read(color, &both("{}", "{}")), "undefined"); + assert_eq!(read(padding, &both("{ p: 2 }", "{}")), "undefined"); + assert_eq!(read(padding, &both("{}", "{ p: 2 }")), "2"); +} + +#[test] +#[serial] +fn a_later_own_undefined_clears_what_an_earlier_spread_gave() { + let output = output(&format!( + "{BOX}export const a = (first, last) => ;" + )); + let identifier = &slots(&output)[0].identifier; + let both = |first: &str, last: &str| format!("const first = {first}; const last = {last};"); + + assert_eq!( + read( + identifier, + &both("{ color: 'blue' }", "{ color: undefined }") + ), + "undefined" + ); + assert_eq!( + read(identifier, &both("{ color: 'blue' }", "{ color: null }")), + "null" + ); + assert_eq!( + read(identifier, &both("{ color: 'blue' }", "{}")), + "\"blue\"" + ); +} + +#[test] +#[serial] +fn an_explicit_style_written_after_the_spread_is_the_only_one_that_counts() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "blue".to_string())] + ); +} + +#[test] +#[serial] +fn an_alias_written_later_replaces_the_earlier_key_of_the_property() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + assert_eq!(found[0].variable, "--background-spread-background-0-"); +} + +#[test] +#[serial] +fn a_literal_spread_before_an_unknown_one_gives_way_with_its_own_keys() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let found = slots(&output); + + assert_eq!( + found + .iter() + .map(|slot| (slot.property.as_str(), slot.fallback.as_str())) + .collect::>(), + vec![("color", "red"), ("padding", "4px")] + ); + assert_eq!( + read( + &slot_of(&found, "color").identifier, + "const rest = { color: 'blue', margin: 1 };" + ), + "\"blue\"" + ); + assert_eq!( + read( + &slot_of(&found, "padding").identifier, + "const rest = { color: 'blue', margin: 1 };" + ), + "undefined" + ); +} + +#[test] +#[serial] +fn a_literal_holding_a_spread_is_one_more_source_to_read() { + let output = output(&format!( + "{BOX}export const a = (a, b) => ;" + )); + let found = slots(&output); + + assert_eq!(found.len(), 1); + let both = |a: &str, b: &str| format!("const a = {a}; const b = {b};"); + assert_eq!( + read(&found[0].identifier, &both("{ color: 'x' }", "{}")), + "\"x\"" + ); + assert_eq!( + read( + &found[0].identifier, + &both("{ color: 'x' }", "{ color: 'y' }") + ), + "\"y\"" + ); +} + +#[test] +#[serial] +fn a_literal_spread_after_the_unknown_one_stays_a_plain_style() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); +} + +#[test] +#[serial] +fn a_literal_spread_replaced_by_a_later_attribute_leaves_no_slot() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "blue".to_string())] + ); +} + +#[test] +#[serial] +fn a_literal_spread_keeps_the_props_that_are_not_styles() { + let rendered = code(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert!(rendered.contains("id: \"x\""), "{rendered}"); + assert!(!rendered.contains("color: \"red\""), "{rendered}"); +} + +#[test] +#[serial] +fn two_attributes_of_one_property_do_not_share_a_variable_when_written_differently() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let variables: std::collections::BTreeSet = slots(&output) + .iter() + .map(|slot| slot.variable.clone()) + .collect(); + + assert_eq!( + variables, + std::collections::BTreeSet::from([ + "--margin-left-spread-ml-0-".to_string(), + "--margin-right-spread-mx-0-".to_string() + ]) + ); +} 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..c60d63b6a --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/slots.rs @@ -0,0 +1,259 @@ +use super::*; +use serial_test::serial; + +#[test] +#[serial] +fn explicit_style_wins_when_the_spread_is_written_before_it() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + assert_eq!( + static_styles(&output), + vec![("color".to_string(), "red".to_string())] + ); + assert!(!output.code.contains("rest?.color"), "{}", output.code); +} + +#[test] +#[serial] +fn explicit_style_falls_back_when_a_spread_is_written_after_it() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + let found = slots(&output); + assert_eq!(found.len(), 1); + assert_eq!(found[0].property, "color"); + assert_eq!(found[0].level, 0); + assert_eq!(found[0].variable, "--color-spread-color-0-"); + assert_eq!(found[0].fallback, "red"); + assert!( + output.code.contains("\"--color-spread-color-0-\":"), + "{}", + output.code + ); + assert!(output.code.contains("...rest"), "{}", output.code); +} + +#[test] +#[serial] +fn the_slot_holds_what_the_spread_assigns_as_it_is() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let identifier = &slots(&output)[0].identifier; + + assert_eq!( + read(identifier, "const rest = { color: 'blue' };"), + "\"blue\"" + ); + assert_eq!(read(identifier, "const rest = { color: 3 };"), "3"); + assert_eq!(read(identifier, "const rest = { color: null };"), "null"); + assert_eq!(read(identifier, "const rest = {};"), "undefined"); + assert_eq!(read(identifier, "const rest = null;"), "undefined"); + assert_eq!( + read(identifier, "const rest = { color: undefined };"), + "undefined" + ); +} + +#[test] +#[serial] +fn a_key_the_spread_only_inherits_is_not_a_key_it_assigns() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let identifier = &slots(&output)[0].identifier; + + assert_eq!( + read( + identifier, + "const rest = Object.create({ color: 'inherited' });" + ), + "undefined" + ); + assert_eq!( + read( + identifier, + "const rest = Object.defineProperty({}, 'color', { value: 'hidden' });" + ), + "undefined" + ); +} + +#[test] +#[serial] +fn a_getter_of_the_spread_runs_once_for_the_slot() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + let identifier = &slots(&output)[0].identifier; + + assert_eq!( + run(&format!( + "let reads = 0; const rest = Object.defineProperty({{}}, 'color', {{ enumerable: true, get() {{ reads += 1; return 'x'; }} }}); const slot = {identifier}; return reads + ':' + slot;" + )), + "1:x" + ); +} + +#[test] +#[serial] +fn keys_only_the_spread_gives_stay_untouched() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 1); + assert!(output.code.contains("...rest"), "{}", output.code); + assert!(output.code.contains("rest.t"), "{}", output.code); +} + +#[test] +#[serial] +fn responsive_values_share_one_override_variable_with_their_own_fallbacks() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + let found = slots(&output); + assert_eq!( + found + .iter() + .map(|slot| (slot.level, slot.fallback.as_str())) + .collect::>(), + vec![(0, "red"), (1, "blue"), (3, "$primary")] + ); + assert!(found.iter().all(|slot| { + slot.identifier == found[0].identifier && slot.variable == "--background-spread-bg-0-" + })); + assert_eq!( + output + .code + .matches("\"--background-spread-bg-0-\":") + .count(), + 1, + "{}", + output.code + ); +} + +#[test] +#[serial] +fn distinct_fallbacks_are_distinct_classes() { + let output = output(&format!( + "{BOX}export const a = (rest) => <>;" + )); + + let classes: std::collections::BTreeSet = output + .styles + .iter() + .filter_map(|style| match style.extract(None) { + Some(StyleProperty::Variable { class_name, .. }) => Some(class_name), + _ => None, + }) + .collect(); + assert_eq!(classes.len(), 2, "{classes:?}"); +} + +#[test] +#[serial] +fn a_later_own_undefined_does_not_bring_back_the_runtime_value() { + let output = output(&format!( + "{BOX}export const a = (rest, x) => ;" + )); + + assert_eq!(slots(&output).len(), 0); + let identifier = output + .styles + .iter() + .find_map(|style| match style { + ExtractStyleValue::Dynamic(dynamic) => { + Some(captures::materialized(dynamic.identifier(), &output.code)) + } + _ => None, + }) + .unwrap_or_else(|| panic!("a dynamic style: {}", output.code)); + let setup = |rest: &str| format!("const x = 5; const rest = {rest};"); + assert_eq!(read(&identifier, &setup("{}")), "5"); + assert_eq!(read(&identifier, &setup("{ p: 3 }")), "3"); + assert_eq!(read(&identifier, &setup("{ p: undefined }")), "undefined"); + assert_eq!(read(&identifier, &setup("{ p: null }")), "null"); +} + +#[test] +#[serial] +fn a_conditional_style_gives_way_in_each_branch() { + let output = output(&format!( + "{BOX}export const a = (rest, on) => ;" + )); + + assert_eq!(slots(&output).len(), 2); +} + +#[test] +#[serial] +fn style_order_stays_with_a_style_that_gives_way() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert_eq!(slots(&output).len(), 1); + assert!(output.code.contains("--4-"), "{}", output.code); +} + +#[test] +#[serial] +fn a_spread_source_is_evaluated_once_in_the_order_written() { + let output = output(&format!( + "{BOX}export const a = (f) => ;" + )); + + assert_eq!(output.code.matches("f()").count(), 1, "{}", output.code); + assert!(output.code.contains("__devupSpread0"), "{}", output.code); +} + +#[test] +#[serial] +fn non_identifier_spread_sources_are_read_through_parentheses() { + let output = output(&format!( + "{BOX}export const a = (p) => ;" + )); + + let identifier = &slots(&output)[0].identifier; + assert_eq!( + read(identifier, "const p = {on:null,b:{color:'blue'}};"), + "\"blue\"" + ); + assert_eq!(output.code.matches("p.on ?? p.b").count(), 1); +} + +#[test] +#[serial] +fn an_object_literal_holding_a_spread_or_a_computed_key_may_carry_the_key() { + let output = output(&format!( + "{BOX}export const a = (rest) => ;\nexport const b = (k) => ;" + )); + + let found = slots(&output); + assert_eq!(found.len(), 2); + let actual = super::whole::evaluate( + &format!( + "{BOX}export const a=(rest)=>; export const b=(k)=>;" + ), + "[a({color:'blue'}).props.color,b('color').props.color]", + ); + assert_eq!(actual.element, serde_json::json!(["blue", 1])); +} + +#[test] +#[serial] +fn a_key_that_is_not_an_identifier_is_read_by_name() { + let rendered = code(&format!( + "{BOX}export const a = (rest) => ;" + )); + + assert!(rendered.contains("[\"background-color\"]"), "{rendered}"); +} 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..74b2b08ce --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/style_order.rs @@ -0,0 +1,78 @@ +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 g = ;\nexport const h = ;" + )); + + for order in ["--3-", "--5-", "--255-", "--1-", "--2-", "--7-"] { + assert!(rendered.contains(order), "{order}: {rendered}"); + } +} + +#[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..fb020a634 --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/styled.rs @@ -0,0 +1,155 @@ +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_eq!(slots(&output).len(), 0); + assert!( + static_styles(&output).contains(&("color".to_string(), "red".to_string())), + "{}", + output.code + ); + assert!( + output.code.contains(""); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].property, "color"); + assert_eq!(found[0].fallback, "red"); + assert_eq!( + read(&found[0].identifier, "const props = { color: 'blue' };"), + "\"blue\"" + ); + assert_eq!( + read(&found[0].identifier, "const props = { color: undefined };"), + "undefined" + ); + 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(""); + let found = slots(&output); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].fallback, "blue"); + assert_eq!( + read( + &found[0].identifier, + "const props = { color: 'x', more: {} };" + ), + "undefined" + ); + assert_eq!( + read( + &found[0].identifier, + "const props = { more: { color: 'y' } };" + ), + "\"y\"" + ); +} + +#[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_eq!(slots(&output).len(), 1); + 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..65531659b --- /dev/null +++ b/libs/extractor/src/jsx_semantics_tests/whole.rs @@ -0,0 +1,56 @@ +//! 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; + 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..805aec0c4 --- /dev/null +++ b/libs/extractor/src/legacy_semantics_contract_tests.rs @@ -0,0 +1,170 @@ +//! Negative cases paired with legacy snapshots. Unknown spreads must precede +//! explicit selector/typography props; lexical self-initialization is a TDZ error, +//! unlike the hoisted `var` self-read retained as the positive dynamic control. + +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 message = error("test.tsx", source, core_option()); + + assert!( + message.starts_with("test.tsx:11:7: `` cannot use `typography`"), + "{message}" + ); + assert!( + message.contains("write the spread before `typography`"), + "{message}" + ); +} + +pub(super) fn extract_selector() { + let source = r"import {Center} from '@devup-ui/core' +
+ {children} +
+ "; + + let message = error("test.tsx", source, core_option()); + + for (location, key) in [("test.tsx:3:7", "_active"), ("test.tsx:9:7", "_hover")] { + assert!( + message.contains(&format!("{location}: `
` cannot use `{key}`")), + "{message}" + ); + assert!( + message.contains(&format!("replace the whole `{key}` object")), + "{message}" + ); + assert!( + message.contains(&format!("write the spread before `{key}`")), + "{message}" + ); + } +} + +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 message = error("test.jsx", source, core_option()); + + assert!( + message.starts_with("test.jsx:10:7: `` cannot use `_active`"), + "{message}" + ); + assert!( + message.contains("replace the whole `_active` object"), + "{message}" + ); + assert!( + message.contains("write the spread before `_active`"), + "{message}" + ); +} + +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 2fe3ae844..bbdcd1a2b 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -11,6 +11,10 @@ mod gen_class_name; mod gen_style; mod import_alias_visit; mod imported_constants; +#[cfg(test)] +mod jsx_semantics_tests; +#[cfg(test)] +mod legacy_semantics_contract_tests; mod module_loader; mod mutations; mod prop_modify_utils; @@ -1874,6 +1878,7 @@ mod tests { import clsx from 'clsx' "#, ExtractOption { @@ -1901,6 +1905,7 @@ import clsx from 'clsx' ) .unwrap() )); + crate::legacy_semantics_contract_tests::extract_style_props_with_class_name(); } #[test] @@ -3114,6 +3119,7 @@ import clsx from 'clsx' "test.tsx", r"import {Center} from '@devup-ui/core'
{children}
@@ -3487,6 +3492,7 @@ import clsx from 'clsx' ) .unwrap() )); + crate::legacy_semantics_contract_tests::extract_selector(); } #[test] @@ -5145,7 +5151,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] @@ -5346,12 +5352,12 @@ export default function Card({ }) { return ( {children} @@ -5369,6 +5375,7 @@ export default function Card({ ) .unwrap() )); + crate::legacy_semantics_contract_tests::test_rest_props(); } #[test] @@ -7061,10 +7068,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' @@ -7078,13 +7086,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' @@ -7098,8 +7107,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 @@ -7319,10 +7329,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"; @@ -7339,8 +7349,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(); @@ -9407,7 +9418,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) }); @@ -9418,6 +9429,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\" />", @@ -14999,7 +15011,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) { @@ -19833,7 +19845,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/prop_modify_utils.rs b/libs/extractor/src/prop_modify_utils.rs index d50e868a4..6c17b3d15 100644 --- a/libs/extractor/src/prop_modify_utils.rs +++ b/libs/extractor/src/prop_modify_utils.rs @@ -559,7 +559,7 @@ fn apply_style_order_to_styles(styles: &mut [ExtractStyleValue], style_order: Op /// Extract Tailwind CSS styles from a static className string and generate devup-ui class names /// Returns (extracted styles for CSS generation, generated class names expression) -fn extract_tailwind_from_class_name<'a>( +pub(crate) fn extract_tailwind_from_class_name<'a>( ast_builder: &AstBuilder<'a>, class_name_prop: &Option>, style_order: Option, @@ -878,11 +878,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( @@ -899,6 +903,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/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..f23d298c9 100644 --- a/libs/extractor/src/scope/tests/diagnostics.rs +++ b/libs/extractor/src/scope/tests/diagnostics.rs @@ -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__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..012b46db2 100644 --- a/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap +++ b/libs/extractor/src/snapshots/extractor__tests__changed_constants.snap @@ -9,19 +9,19 @@ expression: outputs "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 816a032aa..992ca93d6 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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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 66c9f653d..7681ee68f 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)} as={forwardedAs} className={[\"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-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)} as={forwardedAs} className={[\"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) => ((__devupSpread1) =>
)({\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 a06c4cea3..86690fb25 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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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)} as={forwardedAs} className={[\"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..16d9defad 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: "\"w\" in __devupSpread1 ? __devupSpread1.w : \"w\" in __devupSpread0 ? __devupSpread0.w : (__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
- +
" `; @@ -111,7 +111,7 @@ exports[`Input should render disabled icon style when disabled is true 1`] = ` exports[`Controlled Input should render with value 1`] = ` "
- +
" `; diff --git a/packages/components/src/components/Input/__tests__/index.browser.test.tsx b/packages/components/src/components/Input/__tests__/index.browser.test.tsx index 8f7527f65..b088ef4eb 100644 --- a/packages/components/src/components/Input/__tests__/index.browser.test.tsx +++ b/packages/components/src/components/Input/__tests__/index.browser.test.tsx @@ -21,7 +21,7 @@ describe('Input', () => { const { container } = render() expect(container).toMatchSnapshot() expect(container.querySelector('[aria-label="input"]')).toHaveClass( - 'padding-right-0-36px--1', + 'padding-right-0-var_lp_--padding-right-spread-pr-0-_cm_36px_rp_--1', ) }) @@ -61,7 +61,7 @@ describe('Input', () => { const { container } = render() expect(container).toMatchSnapshot() expect(container.querySelector('[aria-label="input"]')).toHaveClass( - 'border-color-0-var_lp_--error_cm_light-dark_lp__h_D52B2E_cm__h_FF5B5E_rp__rp_--1', + 'border-color-0-var_lp_--border-color-spread-borderColor-0-_cm_var_lp_--error_cm_light-dark_lp__h_D52B2E_cm__h_FF5B5E_rp__rp__rp_--1', ) }) diff --git a/packages/components/src/components/Input/index.tsx b/packages/components/src/components/Input/index.tsx index ceff73d7a..a972de1b6 100644 --- a/packages/components/src/components/Input/index.tsx +++ b/packages/components/src/components/Input/index.tsx @@ -101,25 +101,6 @@ export function Input({ )} {clearButtonVisible && } {error && errorMessage && ( diff --git a/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap index 38cb80421..c8f615af4 100644 --- a/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Radio/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -3,7 +3,7 @@ exports[`Radio should Radio snapshot 1`] = ` "
@@ -13,7 +13,7 @@ exports[`Radio should Radio snapshot 1`] = ` exports[`Radio should Radio snapshot 2`] = ` "
@@ -23,7 +23,7 @@ exports[`Radio should Radio snapshot 2`] = ` exports[`Radio should Radio snapshot 3`] = ` "
@@ -33,7 +33,7 @@ exports[`Radio should Radio snapshot 3`] = ` exports[`Radio should Radio snapshot 4`] = ` "
@@ -43,7 +43,7 @@ exports[`Radio should Radio snapshot 4`] = ` exports[`Radio should Radio snapshot 5`] = ` "
@@ -53,7 +53,7 @@ exports[`Radio should Radio snapshot 5`] = ` exports[`Radio should Radio snapshot 6`] = ` "
@@ -63,7 +63,7 @@ exports[`Radio should Radio snapshot 6`] = ` exports[`Radio should Radio snapshot 7`] = ` "
@@ -73,7 +73,7 @@ exports[`Radio should Radio snapshot 7`] = ` exports[`Radio should Radio snapshot 8`] = ` "
@@ -83,7 +83,7 @@ exports[`Radio should Radio snapshot 8`] = ` exports[`Radio should Radio snapshot 9`] = ` "
@@ -93,7 +93,7 @@ exports[`Radio should Radio snapshot 9`] = ` exports[`Radio should Radio snapshot 10`] = ` "
@@ -103,7 +103,7 @@ exports[`Radio should Radio snapshot 10`] = ` exports[`Radio should Radio snapshot 11`] = ` "
diff --git a/packages/components/src/components/Radio/index.tsx b/packages/components/src/components/Radio/index.tsx index b6b5e921f..57559f2ba 100644 --- a/packages/components/src/components/Radio/index.tsx +++ b/packages/components/src/components/Radio/index.tsx @@ -81,9 +81,6 @@ export function Radio({ /> ) : ( )} {variant === 'button' ? ( diff --git a/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap index 002792775..64236928a 100644 --- a/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/RadioGroup/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -4,13 +4,13 @@ exports[`RadioGroup should RadioGroup snapshot 1`] = ` "