From 058189f140f9e16e6b043fd2a88744f0e72de189 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sat, 17 Oct 2026 23:26:32 +0900 Subject: [PATCH] fix(extractor): preserve exact literal coercion Refs #686 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- ...changepack_log_exact_literal_coercion.json | 7 + libs/extractor/src/build_time_values.rs | 9 +- libs/extractor/src/css_utils.rs | 6 +- .../extract_style_from_member_expression.rs | 6 +- libs/extractor/src/lib.rs | 319 ++++++++++++++++++ libs/extractor/src/mutations.rs | 2 +- libs/extractor/src/utils.rs | 42 ++- libs/extractor/src/visit.rs | 13 +- 8 files changed, 379 insertions(+), 25 deletions(-) create mode 100644 .changepacks/changepack_log_exact_literal_coercion.json diff --git a/.changepacks/changepack_log_exact_literal_coercion.json b/.changepacks/changepack_log_exact_literal_coercion.json new file mode 100644 index 000000000..aef5f3024 --- /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 1e8bdd7e3..a3fe26720 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 c9545f989..35630a4ec 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 62e5c9f19..5727af208 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/lib.rs b/libs/extractor/src/lib.rs index 7a90f4184..9c7f94f1f 100644 --- a/libs/extractor/src/lib.rs +++ b/libs/extractor/src/lib.rs @@ -845,6 +845,325 @@ 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 { + 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 0c49cae63..2dcd32b19 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/utils.rs b/libs/extractor/src/utils.rs index 296af5bc8..9310dc78f 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) @@ -1305,14 +1329,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 31c48b95d..68fae9eae 100644 --- a/libs/extractor/src/visit.rs +++ b/libs/extractor/src/visit.rs @@ -59,11 +59,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, 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, + 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}; @@ -932,7 +933,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,