diff --git a/.changepacks/changepack_log_exact_literal_coercion.json b/.changepacks/changepack_log_exact_literal_coercion.json new file mode 100644 index 00000000..aef5f302 --- /dev/null +++ b/.changepacks/changepack_log_exact_literal_coercion.json @@ -0,0 +1,7 @@ +{ + "changes": { + "bindings/devup-ui-wasm/package.json": "Patch" + }, + "note": "Preserve exact JavaScript literal property keys in static lookups and generated finite class maps, independently of CSS numeric value normalization. Only fold standalone unary string coercion when its spelling is proven to agree with JavaScript Number; other values retain the existing conservative dynamic or located build-error path without a styling runtime.", + "date": "2026-10-17T00:00:00.000Z" +} diff --git a/libs/extractor/src/build_time_values.rs b/libs/extractor/src/build_time_values.rs index 6f95de3a..d1ee2317 100644 --- a/libs/extractor/src/build_time_values.rs +++ b/libs/extractor/src/build_time_values.rs @@ -22,7 +22,10 @@ use oxc_syntax::symbol::SymbolId; use rustc_hash::{FxHashMap, FxHashSet}; use crate::stylex::StylexFunction; -use crate::utils::{binding_root, get_string_by_literal_expression, unwrap_syntax_only}; +use crate::utils::{ + binding_root, get_key_by_literal_expression, get_string_by_literal_expression, + unwrap_syntax_only, +}; use crate::{ExtractOption, ModuleResolver}; /// What code reads outside itself @@ -765,7 +768,7 @@ impl<'a> Visit<'a> for Reads<'_> { &mut self, member: &oxc_ast::ast::ComputedMemberExpression<'a>, ) { - if let Some(key) = get_string_by_literal_expression(&member.expression) { + if let Some(key) = get_key_by_literal_expression(&member.expression) { self.member(&member.object, &key); if self.is_global(&member.object, "Math") { return; @@ -778,7 +781,7 @@ impl<'a> Visit<'a> for Reads<'_> { match unwrap_syntax_only(&call.callee) { // A method chosen at runtime may be any Expression::ComputedMemberExpression(member) => { - self.impure |= get_string_by_literal_expression(&member.expression).is_none(); + self.impure |= get_key_by_literal_expression(&member.expression).is_none(); } // Exact only without a radix Expression::StaticMemberExpression(member) if member.property.name == "toString" => { diff --git a/libs/extractor/src/css_utils.rs b/libs/extractor/src/css_utils.rs index c9545f98..35630a4e 100644 --- a/libs/extractor/src/css_utils.rs +++ b/libs/extractor/src/css_utils.rs @@ -3,8 +3,8 @@ use std::collections::BTreeMap; use std::fmt::Write as _; use crate::utils::{ - get_str_by_property_key, get_string_by_literal_expression, readable_code, runtime_value_error, - wrap_direct_call, + get_key_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, + readable_code, runtime_value_error, wrap_direct_call, }; use css::{ at_rule::split_at_rule_key, @@ -79,7 +79,7 @@ pub(crate) fn theme_var_reference(expr: &Expression<'_>) -> Option { } // `theme.space[2]` and `theme.colors['brand']` read a key the build knows Expression::ComputedMemberExpression(member) => { - path.push(get_string_by_literal_expression(&member.expression)?); + path.push(get_key_by_literal_expression(&member.expression)?); cursor = &member.object; } Expression::Identifier(ident) => { diff --git a/libs/extractor/src/extractor/extract_style_from_member_expression.rs b/libs/extractor/src/extractor/extract_style_from_member_expression.rs index 62e5c9f1..5727af20 100644 --- a/libs/extractor/src/extractor/extract_style_from_member_expression.rs +++ b/libs/extractor/src/extractor/extract_style_from_member_expression.rs @@ -7,8 +7,8 @@ use crate::{ }, }, utils::{ - get_number_by_literal_expression, get_str_by_property_key, - get_string_by_literal_expression, get_string_by_property_key, readable_code, + get_key_by_literal_expression, get_number_by_literal_expression, get_str_by_property_key, + get_string_by_property_key, readable_code, }, }; use css::style_selector::StyleSelector; @@ -136,7 +136,7 @@ pub(super) fn extract_style_from_member_expression<'a>( && !obj.properties.is_empty() { let mut map = BTreeMap::new(); - if let Some(k) = get_string_by_literal_expression(mem_expression) { + if let Some(k) = get_key_by_literal_expression(mem_expression) { // The last property written for the key gives it, unless a spread // after it may replace it let written = obj.properties.iter().rposition(|p| { diff --git a/libs/extractor/src/extractor/styled_record_selection.rs b/libs/extractor/src/extractor/styled_record_selection.rs index d8abb534..f74f7a40 100644 --- a/libs/extractor/src/extractor/styled_record_selection.rs +++ b/libs/extractor/src/extractor/styled_record_selection.rs @@ -1,6 +1,6 @@ use super::Preparation; use crate::utils::{ - get_str_by_property_key, get_string_by_literal_expression, js_number_string, unwrap_syntax_only, + get_key_by_literal_expression, get_str_by_property_key, js_number_string, unwrap_syntax_only, }; use oxc_allocator::{CloneIn, FromIn, GetAllocator, Vec as ArenaVec}; use oxc_ast::ast::{ @@ -35,7 +35,7 @@ pub(super) fn direct_lookup(object: &Expression<'_>, index: &Expression<'_>) -> let Some(key) = literal_key(index) else { return false; }; - if get_string_by_literal_expression(index).as_deref() != Some(key.as_str()) { + if get_key_by_literal_expression(index).as_deref() != Some(key.as_str()) { return false; } match object { diff --git a/libs/extractor/src/extractor/styled_record_selection_tests.rs b/libs/extractor/src/extractor/styled_record_selection_tests.rs index cb35a455..fae9293b 100644 --- a/libs/extractor/src/extractor/styled_record_selection_tests.rs +++ b/libs/extractor/src/extractor/styled_record_selection_tests.rs @@ -7,6 +7,36 @@ use rstest::rstest; use super::{Expression, Preparation, direct_lookup}; use crate::{extractor::styled_record_evaluation::Evaluations, utils::unwrap_syntax_only}; +#[rstest] +#[case::leading_zero("({'05':'red','5':'blue'})['05'];", true)] +#[case::canonical_integer("({'05':'red','5':'blue'})['5'];", true)] +#[case::decimal("({'0.50':'red','0.5':'blue'})['0.50'];", true)] +#[case::canonical_decimal("({'0.50':'red','0.5':'blue'})['0.5'];", true)] +#[case::ts_as_index("({color:'red'})['color' as const];", false)] +fn direct_lookup_preserves_ast_when_checking_exact_index_agreement( + #[case] source: &str, + #[case] expected: bool, +) { + // Given: real parsed object/index syntax, including a TS-as index wrapper. + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, source, SourceType::tsx()).parse(); + assert_eq!(parsed.diagnostics.len(), 0); + let Statement::ExpressionStatement(statement) = &parsed.program.body[0] else { + panic!("test fixture must be an expression"); + }; + let Expression::ComputedMemberExpression(member) = &statement.expression else { + panic!("test fixture must be a computed member"); + }; + let original = format!("{member:?}"); + + // When: the caller supplies the syntax-unwrapped object as Preparation::value does. + let accepted = direct_lookup(unwrap_syntax_only(&member.object), &member.expression); + + // Then: exact object keys agree, but the unsupported TS-as index stays native. + assert_eq!(accepted, expected, "{source}"); + assert_eq!(format!("{member:?}"), original); +} + #[test] fn direct_lookup_rejects_when_object_is_not_literal() { // Given: a literal key that passes the key checks, but an unknown object. diff --git a/libs/extractor/src/lib.rs b/libs/extractor/src/lib.rs index 4628bc63..610555f6 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -884,6 +884,327 @@ pub fn has_devup_ui(filename: &str, code: &str, package: &str) -> bool { #[cfg(test)] #[allow(clippy::expect_used, clippy::unwrap_used)] mod tests { + mod exact_literal_coercion { + include!("stylex_exact_key_tests.rs"); + + use super::*; + use crate::extract_style::style_property::StyleProperty; + use boa_engine::{Context, Source}; + use oxc_ast::ast::{ImportDeclarationSpecifier, Statement}; + use oxc_codegen::Codegen; + use std::fmt::Write as _; + + fn render_observation( + output: &ExtractOutput, + invocation: &str, + filename: Option<&str>, + ) -> String { + let arena = Allocator::default(); + let parsed = Parser::new(&arena, &output.code, SourceType::mjs()).parse(); + assert_eq!(parsed.diagnostics.len(), 0, "{}", output.code); + let mut program = parsed.program; + let mut factories = String::new(); + for statement in &program.body { + if let Statement::ImportDeclaration(import) = statement + && import.source.value == "react/jsx-runtime" + { + for specifier in import.specifiers.iter().flatten() { + let ImportDeclarationSpecifier::ImportSpecifier(named) = specifier else { + panic!("unexpected JSX factory import: {specifier:?}"); + }; + assert_eq!(named.imported.to_string(), "jsx"); + write!( + factories, + "const {} = (target, props) => ({{target, props}});", + named.local.name + ) + .unwrap(); + } + } + } + program + .body + .retain(|statement| !matches!(statement, Statement::ImportDeclaration(_))); + let declarations = output + .styles + .iter() + .map(|style| match (style, style.extract(filename)) { + (ExtractStyleValue::Static(style), Some(StyleProperty::ClassName(class))) => { + format!("[{class:?}, () => {:?}]", style.value()) + } + ( + ExtractStyleValue::Dynamic(_), + Some(StyleProperty::Variable { + class_name, + variable_name, + .. + }), + ) => { + format!("[{class_name:?}, () => (props.style || {{}})[{variable_name:?}]]") + } + other => panic!("unexpected declaration: {:?}", other.0), + }) + .collect::>() + .join(","); + let script = format!( + "{factories}{}\nconst trace=[]; const props=({invocation}).props;\nconst active=new Set((props.className || '').split(/\\s+/));\nJSON.stringify([trace,[{declarations}].filter(([name])=>active.has(name)).map(([,value])=>value())]);", + Codegen::new().build(&program).code + ); + Context::default() + .eval(Source::from_bytes(&script)) + .unwrap_or_else(|error| panic!("generated renderer failed: {error}\n{script}")) + .as_string() + .expect("renderer returns JSON text") + .to_std_string_escaped() + } + + #[rstest] + #[case::leading_zero(("{'05':'red','5':'blue'}['05']", "red"))] + #[case::decimal(("{'0.50':'red','0.5':'blue'}['0.50']", "red"))] + #[case::canonical_integer(("{'05':'red','5':'blue'}['5']", "blue"))] + #[case::canonical_decimal(("{'0.50':'red','0.5':'blue'}['0.5']", "blue"))] + #[case::computed_keys(("{['05']:'red',['5']:'blue'}['05']", "red"))] + #[case::template_key(("{'05':'red','5':'blue'}[`${'05'}`]", "red"))] + #[case::numeric_key(("{'05':'red',5:'blue'}[5]", "blue"))] + #[serial] + fn exact_literal_key_selects_authored_property( + #[case] sample: (&str, &str), + #[values(false, true)] single_css: bool, + ) { + // Given + reset_class_map(); + reset_file_map(); + let (expression, expected) = sample; + let source = format!( + "import {{Box}} from '@devup-ui/react'; import {{jsx}} from 'react/jsx-runtime'; function render() {{ return jsx(Box, {{color: ({expression})}}); }}" + ); + // When + let output = extract( + "exact-key.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + // Then: an unused red CSS rule alone is not evidence of correct selection. + assert_eq!( + render_observation(&output, "render()", (!single_css).then_some("exact-key.js")), + format!("[[],[{expected:?}]]") + ); + } + + #[rstest] + #[case::leading_zero(("{'05':'red','5':'blue'}", "05", "red"))] + #[case::canonical_integer(("{'05':'red','5':'blue'}", "5", "blue"))] + #[case::decimal(("{'0.50':'red','0.5':'blue'}", "0.50", "red"))] + #[case::canonical_decimal(("{'0.50':'red','0.5':'blue'}", "0.5", "blue"))] + #[case::computed_keys(("{['05']:'red',['5']:'blue'}", "05", "red"))] + #[case::computed_decimal(("{['0.50']:'red',['0.5']:'blue'}", "0.50", "red"))] + #[serial] + fn exact_literal_keys_survive_generated_finite_map( + #[case] sample: (&str, &str, &str), + #[values(false, true)] single_css: bool, + ) { + // Given: the key is unknown during extraction and read once during rendering. + reset_class_map(); + reset_file_map(); + let (object, key, expected) = sample; + let source = format!( + "import {{Box}} from '@devup-ui/react'; import {{jsx}} from 'react/jsx-runtime'; function render(key, trace) {{ return jsx(Box, {{color: ({object})[(trace.push('key'), key)]}}); }}" + ); + // When + let output = extract( + "exact-map.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + // Then: test the applied class, not merely both possible declarations. + assert_eq!( + render_observation( + &output, + &format!("render({key:?}, trace)"), + (!single_css).then_some("exact-map.js") + ), + format!("[[\"key\"],[{expected:?}]]") + ); + } + + #[rstest] + #[case::inf(("+'inf'", "NaN", "[]"))] + #[case::negative_inf(("-'inf'", "NaN", "[]"))] + #[case::signed_inf(("+'+inf'", "NaN", "[]"))] + #[case::signed_negative_inf(("+'-inf'", "NaN", "[]"))] + #[case::upper_inf(("+'INF'", "NaN", "[]"))] + #[case::mixed_inf(("+'InF'", "NaN", "[]"))] + #[case::lower_infinity(("+'infinity'", "NaN", "[]"))] + #[case::upper_infinity(("+'INFINITY'", "NaN", "[]"))] + #[case::mixed_infinity(("+'iNfInItY'", "NaN", "[]"))] + #[case::signed_alias(("+'-infinity'", "NaN", "[]"))] + #[case::infinity(("+'Infinity'", "Infinity", "[]"))] + #[case::positive_infinity(("+'+Infinity'", "Infinity", "[]"))] + #[case::negative_infinity(("+'-Infinity'", "-Infinity", "[]"))] + #[case::negated_infinity(("-'Infinity'", "-Infinity", "[]"))] + #[case::empty(("+''", "0", "[]"))] + #[case::whitespace((r"+' \t\n'", "0", "[]"))] + #[case::trimmed_decimal(("+' 05 '", "5", "[]"))] + #[case::hex(("+'0x10'", "16", "[]"))] + #[case::signed_hex(("+'+0x10'", "NaN", "[]"))] + #[case::decimal(("+'1.50'", "1.5", "[]"))] + #[case::negative_zero(("-'-0'", "0", "[]"))] + #[case::effectful_alias(("+(trace.push('operand'), 'inf')", "NaN", r#"["operand"]"#))] + #[case::effectful_hex(("+(trace.push('operand'), '0x10')", "16", r#"["operand"]"#))] + #[case::coercion_hook(("+{toString(){trace.push('coerce');return 'inf'}}", "NaN", r#"["coerce"]"#))] + #[case::signed_upper_inf(("+'+INF'", "NaN", "[]"))] + #[case::signed_mixed_inf(("-'-InF'", "NaN", "[]"))] + #[case::signed_upper_infinity(("+'+INFINITY'", "NaN", "[]"))] + #[case::signed_mixed_infinity(("-'-iNfInItY'", "NaN", "[]"))] + #[case::spaced_alias(("+' +inf '", "NaN", "[]"))] + #[case::escaped_whitespace_alias((r"+'\t-infinity\n'", "NaN", "[]"))] + #[case::bom_alias((r"+'\uFEFF+InF\uFEFF'", "NaN", "[]"))] + #[case::parenthesized_alias(("+('-inf')", "NaN", "[]"))] + #[serial] + fn standalone_unary_string_coercion_preserves_quoted_content( + #[case] sample: (&str, &str, &str), + #[values(false, true)] single_css: bool, + ) { + // Given: the operand is a string, not the template-only #785 guarded path. + reset_class_map(); + reset_file_map(); + let (operand, expected, trace) = sample; + let source = format!( + "import {{Box}} from '@devup-ui/react'; import {{jsx}} from 'react/jsx-runtime'; function render(trace) {{ return jsx(Box, {{content: `\"value:${{{operand}}}\"`}}); }}" + ); + // When + let output = extract( + "unary-string.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + // Then: either exact static folding or the retained dynamic expression is valid. + assert_eq!( + render_observation( + &output, + "render(trace)", + (!single_css).then_some("unary-string.js") + ), + format!("[{trace},[{:?}]]", format!("\"value:{expected}\"")) + ); + } + + #[rstest] + #[case::signed_inf("'+inf'")] + #[case::signed_negative_inf("'-inf'")] + #[case::signed_infinity("'-infinity'")] + #[case::signed_cased("'+INFINITY'")] + #[case::whitespace(r"'\t-InF\n'")] + #[case::bom(r"'\uFEFF+inf\uFEFF'")] + #[serial] + fn lowered_const_bound_invalid_alias_preserves_dynamic_operand( + #[case] value: &str, + #[values(false, true)] single_css: bool, + ) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{Box}} from '@devup-ui/react'; import {{jsx}} from 'react/jsx-runtime'; const alias={value}; function render() {{ return jsx(Box, {{content: `\"value:${{+alias}}\"`}}); }}" + ); + // When + let output = extract( + "const-alias.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + // Then: Boa miscoerces signed inf; native SSR covers execution of this operand. + assert_eq!(output.styles.len(), 1); + let Some(ExtractStyleValue::Dynamic(style)) = output.styles.iter().next() else { + panic!( + "expected one dynamic content declaration: {:?}", + output.styles + ); + }; + let operand = r#"`"value:${+alias}"`"#; + assert_eq!((style.property(), style.identifier()), ("content", operand)); + assert_eq!(output.code.matches(operand).count(), 1); + } + + #[rstest] + #[case::signed_inf("'+inf'")] + #[case::signed_negative_inf("'-inf'")] + #[case::signed_infinity("'-infinity'")] + #[case::signed_cased("'+INFINITY'")] + #[case::whitespace(r"'\t-InF\n'")] + #[case::bom(r"'\uFEFF+inf\uFEFF'")] + #[serial] + fn const_bound_invalid_alias_is_proven_before_unary_coercion( + #[case] value: &str, + #[values(false, true)] single_css: bool, + ) { + // Given + reset_class_map(); + reset_file_map(); + let source = format!( + "import {{Box}} from '@devup-ui/react'; const alias={value}; function render() {{ return ; }}" + ); + // When + let output = extract( + "const-alias.tsx", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + // Then + assert_eq!(output.styles.len(), 1); + let Some(ExtractStyleValue::Static(style)) = output.styles.iter().next() else { + panic!( + "expected one static content declaration: {:?}", + output.styles + ); + }; + assert_eq!( + (style.property(), style.value()), + ("content", "\"value:NaN\"") + ); + } + + #[rstest] + #[case::plain("'inf'", "inf")] + #[case::signed("'+inf'", "+inf")] + #[case::cased("'-INFINITY'", "-INFINITY")] + #[case::decimal("'1.50'", "1.5")] + fn css_literal_text_does_not_inherit_unary_nan_proof( + #[case] source: &str, + #[case] expected: &str, + ) { + // Given + let arena = Allocator::default(); + let expression = Parser::new(&arena, source, SourceType::mjs()) + .parse_expression() + .unwrap(); + // When + let text = crate::utils::get_string_by_literal_expression(&expression); + // Then + assert_eq!(text.as_deref(), Some(expected)); + } + } + mod emotion_namespace_tests { include!("emotion_namespace_tests.rs"); } diff --git a/libs/extractor/src/mutations.rs b/libs/extractor/src/mutations.rs index 0c49cae6..2dcd32b1 100644 --- a/libs/extractor/src/mutations.rs +++ b/libs/extractor/src/mutations.rs @@ -164,7 +164,7 @@ impl Context<'_, '_> { } AstKind::ComputedMemberExpression(member) if member.object.span() == span => { path.push( - crate::utils::get_string_by_literal_expression(&member.expression) + crate::utils::get_key_by_literal_expression(&member.expression) .map(std::borrow::Cow::into_owned), ); } diff --git a/libs/extractor/src/styled_source_tests/finite_literal_keys.rs b/libs/extractor/src/styled_source_tests/finite_literal_keys.rs index 5e574277..7a7da0a6 100644 --- a/libs/extractor/src/styled_source_tests/finite_literal_keys.rs +++ b/libs/extractor/src/styled_source_tests/finite_literal_keys.rs @@ -5,10 +5,10 @@ use oxc_codegen::Codegen; #[rstest] #[case::canonical_object(("({1:'red',2:'blue'})[1]", Some("red"), true))] #[case::canonical_array(("['red','blue']['1']", Some("blue"), true))] -#[case::decimal_key(("({'1.0':'red','1':'blue'})['1.0']", Some("red"), false))] -#[case::canonical_index_with_decimal_key(("({'1.0':'red','1':'blue'})['1']", Some("blue"), false))] -#[case::leading_zero_key(("({'01':'red','1':'blue'})['01']", Some("red"), false))] -#[case::canonical_index_with_leading_zero_key(("({'01':'red','1':'blue'})[1]", Some("blue"), false))] +#[case::decimal_key(("({'1.0':'red','1':'blue'})['1.0']", Some("red"), true))] +#[case::canonical_index_with_decimal_key(("({'1.0':'red','1':'blue'})['1']", Some("blue"), true))] +#[case::leading_zero_key(("({'01':'red','1':'blue'})['01']", Some("red"), true))] +#[case::canonical_index_with_leading_zero_key(("({'01':'red','1':'blue'})[1]", Some("blue"), true))] #[case::array_noncanonical_index(("['red','blue']['01']", None, false))] #[case::array_fractional_string_index(("['red','blue']['1.0']", None, false))] #[case::unary_string_index(("({NaN:'red',Infinity:'blue'})[+'inf']", Some("red"), false))] diff --git a/libs/extractor/src/stylex_exact_key_tests.rs b/libs/extractor/src/stylex_exact_key_tests.rs new file mode 100644 index 00000000..8786ccba --- /dev/null +++ b/libs/extractor/src/stylex_exact_key_tests.rs @@ -0,0 +1,83 @@ +mod stylex_exact_keys { + use super::*; + + #[rstest] + #[case::leading_zero_select(("05", "5", "styles['05']", "red"))] + #[case::integer_select(("05", "5", "styles['5']", "blue"))] + #[case::integer_later(("05", "5", "styles['05'], styles['5']", "blue"))] + #[case::leading_zero_later(("05", "5", "styles['5'], styles['05']", "red"))] + #[case::decimal_select(("0.50", "0.5", "styles['0.50']", "red"))] + #[case::canonical_decimal_select(("0.50", "0.5", "styles['0.5']", "blue"))] + #[case::canonical_decimal_later(("0.50", "0.5", "styles['0.50'], styles['0.5']", "blue"))] + #[case::decimal_later(("0.50", "0.5", "styles['0.5'], styles['0.50']", "red"))] + #[serial] + fn applied_color_matches_exact_namespace_when_selecting_or_composing( + #[case] sample: (&str, &str, &str, &str), + #[values(false, true)] single_css: bool, + ) { + // Given: distinct authored colors under keys that CSS numeric normalization collapses. + reset_class_map(); + reset_file_map(); + let (noncanonical, canonical, arguments, expected) = sample; + let source = format!( + "import stylex from '@stylexjs/stylex'; import {{jsx}} from 'react/jsx-runtime'; const styles=stylex.create({{{noncanonical:?}:{{color:'red'}},{canonical:?}:{{color:'blue'}}}}); function render() {{ return jsx('div', {{...stylex.props({arguments})}}); }}" + ); + + // When: the public extractor generates the props and the existing renderer applies them. + let output = extract( + "stylex-exact-key.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + let observation = render_observation( + &output, + "render()", + (!single_css).then_some("stylex-exact-key.js"), + ); + + // Then: exactly the authored winning color is selected; unused atoms cannot satisfy this. + assert_eq!(observation, format!("[[],[{expected:?}]]")); + } + + #[rstest] + #[case::leading_zero(("05", "5", "05", "red"))] + #[case::canonical_integer(("05", "5", "5", "blue"))] + #[case::decimal(("0.50", "0.5", "0.50", "red"))] + #[case::canonical_decimal(("0.50", "0.5", "0.5", "blue"))] + #[serial] + fn applied_color_preserves_exact_namespace_when_dynamic_index_runs_once( + #[case] sample: (&str, &str, &str, &str), + #[values(false, true)] single_css: bool, + ) { + // Given: an unknown key supplied only at render time through an effectful callback. + reset_class_map(); + reset_file_map(); + let (noncanonical, canonical, key, expected) = sample; + let source = format!( + "import stylex from '@stylexjs/stylex'; import {{jsx}} from 'react/jsx-runtime'; const styles=stylex.create({{{noncanonical:?}:{{color:'red'}},{canonical:?}:{{color:'blue'}}}}); function render(choose) {{ return jsx('div', {{...stylex.props(styles[choose()])}}); }}" + ); + + // When: the generated dynamic-index fallback is rendered with the chosen exact key. + let output = extract( + "stylex-dynamic-key.js", + &source, + ExtractOption { + single_css, + ..ExtractOption::default() + }, + ) + .unwrap(); + let observation = render_observation( + &output, + &format!("render(() => {{ trace.push('key'); return {key:?}; }})"), + (!single_css).then_some("stylex-dynamic-key.js"), + ); + + // Then: the actual selected class has the authored color and the key effect occurs once. + assert_eq!(observation, format!("[[\"key\"],[{expected:?}]]")); + } +} diff --git a/libs/extractor/src/utils.rs b/libs/extractor/src/utils.rs index 732f4578..b9b312b8 100644 --- a/libs/extractor/src/utils.rs +++ b/libs/extractor/src/utils.rs @@ -280,9 +280,21 @@ pub(super) fn get_number_by_literal_expression(expr: &Expression) -> Option Expression::ParenthesizedExpression(parenthesized) => { get_number_by_literal_expression(&parenthesized.expression) } - Expression::StringLiteral(sl) => sl.value.parse::().ok(), + Expression::StringLiteral(sl) => { + let text = sl.value.as_str(); + let unsigned = text.strip_prefix(['+', '-']).unwrap_or(text); + if unsigned == "Infinity" + || text + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'+' | b'-' | b'.' | b'e' | b'E')) + { + text.parse::().ok() + } else { + None + } + } Expression::TemplateLiteral(_) => { - let text = get_template_interpolation_string(expr)?; + let text = get_key_by_literal_expression(expr)?; let unsigned = text.strip_prefix(['+', '-']).unwrap_or(&text); // Rust accepts infinity aliases that JavaScript Number rejects. if unsigned.eq_ignore_ascii_case("inf") @@ -294,6 +306,18 @@ pub(super) fn get_number_by_literal_expression(expr: &Expression) -> Option } Expression::NumericLiteral(num) => Some(num.value), Expression::UnaryExpression(unary) => get_number_by_literal_expression(&unary.argument) + .or_else(|| { + let Expression::StringLiteral(value) = unwrap_syntax_only(&unary.argument) else { + return None; + }; + let text = value + .value + .trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}'); + let unsigned = text.strip_prefix(['+', '-']).unwrap_or(text); + (unsigned.eq_ignore_ascii_case("inf") + || (unsigned.eq_ignore_ascii_case("infinity") && unsigned != "Infinity")) + .then_some(f64::NAN) + }) .and_then(|num| match unary.operator { UnaryOperator::UnaryNegation => Some(-num), UnaryOperator::UnaryPlus => Some(num), @@ -337,19 +361,19 @@ pub(crate) fn js_number_string(number: f64) -> String { } } -/// Template substitutions preserve string spelling rather than CSS numeric normalization. -fn get_template_interpolation_string<'a>(expr: &Expression<'a>) -> Option> { +/// Property keys and template substitutions preserve text rather than CSS numeric normalization. +pub(super) fn get_key_by_literal_expression<'a>(expr: &Expression<'a>) -> Option> { match expr { Expression::StringLiteral(value) => Some(Cow::Borrowed(value.value.as_str())), Expression::ParenthesizedExpression(value) => { - get_template_interpolation_string(&value.expression) + get_key_by_literal_expression(&value.expression) } Expression::TemplateLiteral(template) => { let mut text = String::new(); for (index, quasi) in template.quasis.iter().enumerate() { text.push_str(quasi.value.cooked.as_deref()?); if let Some(expression) = template.expressions.get(index) { - text.push_str(&get_template_interpolation_string(expression)?); + text.push_str(&get_key_by_literal_expression(expression)?); } } Some(Cow::Owned(text)) @@ -363,7 +387,7 @@ pub(super) fn get_string_by_literal_expression<'a>(expr: &Expression<'a>) -> Opt Expression::ParenthesizedExpression(value) => { return get_string_by_literal_expression(&value.expression); } - Expression::TemplateLiteral(_) => return get_template_interpolation_string(expr), + Expression::TemplateLiteral(_) => return get_key_by_literal_expression(expr), _ => {} } get_number_by_literal_expression(expr) @@ -1317,14 +1341,14 @@ fn merge_style_properties<'a>( /// /// For a `StaticIdentifier` key this returns `Cow::Borrowed`, avoiding the heap /// allocation the owned variant pays on every prop just to read its key name. -/// The literal fallback now also borrows where possible: `get_string_by_literal_expression` +/// The literal fallback also borrows where possible: `get_key_by_literal_expression` /// returns `Cow::Borrowed` for string/boolean-literal keys and only allocates /// (`Cow::Owned`) for numeric/template keys it must synthesize. pub(super) fn get_str_by_property_key<'k>(key: &PropertyKey<'k>) -> Option> { if let PropertyKey::StaticIdentifier(ident) = key { Some(Cow::Borrowed(ident.name.as_str())) } else if let Some(s) = key.as_expression() { - get_string_by_literal_expression(s) + get_key_by_literal_expression(s) } else { None } diff --git a/libs/extractor/src/visit.rs b/libs/extractor/src/visit.rs index 3ac8bb37..68ba54e1 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -65,11 +65,12 @@ use strum::IntoEnumIterator; use crate::utils::{ ParsedStyleOrder, RUNTIME_VALUE, STYLE_OBJECT, StyleArguments, Suspends, build_time_error, call_with_values, element_error, expression_to_style_order, fixed_value, - get_str_by_property_key, get_string_by_literal_expression, get_string_by_property_key, is_pure, - jsx_expression_to_style_order, key_error, readable_argument, readable_code, reads_directly, - reads_spreads_once, reads_unknown, runtime_classes, runtime_value, runtime_value_error, - spread_error, stays_attribute, string_class, style_arguments, uncomposable_error, - unplaced_error, unreadable_styles, unused_error, unwrap_syntax_only, unwrap_syntax_only_mut, + get_key_by_literal_expression, get_str_by_property_key, get_string_by_literal_expression, + get_string_by_property_key, is_pure, jsx_expression_to_style_order, key_error, + readable_argument, readable_code, reads_directly, reads_spreads_once, reads_unknown, + runtime_classes, runtime_value, runtime_value_error, spread_error, stays_attribute, + string_class, style_arguments, uncomposable_error, unplaced_error, unreadable_styles, + unused_error, unwrap_syntax_only, unwrap_syntax_only_mut, }; use oxc_ast::builder::AstBuilder; use oxc_span::{GetSpan, SPAN}; @@ -1122,7 +1123,7 @@ impl<'a> DevupVisitor<'a> { } Expression::ComputedMemberExpression(member) => ( &member.object, - get_string_by_literal_expression(&member.expression)?.to_string(), + get_key_by_literal_expression(&member.expression)?.to_string(), ), _ => return None, }; @@ -1430,7 +1431,7 @@ impl<'a> DevupVisitor<'a> { }; let ns_map = self.stylex_namespaces.get(obj.name.as_str())?; - if let Some(key) = get_string_by_literal_expression(&member.expression) { + if let Some(key) = get_key_by_literal_expression(&member.expression) { return match ns_map.get(key.as_ref()) { Some(StylexNamespaceValue::Static(cn)) => self.stylex_class_literal(cn), _ => None,