From eb8ebbf955d6f930bec2fbcc1f3960c72e42f03d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 22 Oct 2026 23:29:03 +0800 Subject: [PATCH 1/2] fix(metadata): read selector, exportAs and kept metadata fields with the partial evaluator `selector`, `exportAs` and the check that decides which template-literal fields setClassMetadata keeps now read their value with `evaluate_string_value`, like `template` already does: the const folder first, then the partial evaluator. A template literal that only the evaluator can fold (`${+W}`, `${W * 2}`, an aliased or imported const, an enum member, `let`/`var`, `true`/`null`) now gives the folded selector and exportAs and keeps its fields in setClassMetadata, as in ngtsc. The narrower number map is no longer needed and is removed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/class_metadata/builders.rs | 21 +++++---- .../src/component/decorator.rs | 4 +- .../src/component/transform.rs | 2 +- .../src/directive/decorator.rs | 45 +++---------------- .../oxc_angular_compiler/src/directive/mod.rs | 3 +- .../template_literal_number_const_test.rs | 20 +++++++++ 6 files changed, 43 insertions(+), 52 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index d0b8bbc94..f074a2c62 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -15,7 +15,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::component::{ImportMap, NamespaceRegistry, R3DependencyMetadata}; use crate::directive::{ - QUERY_APIS, R3InputMetadata, StringConsts, initializer_api_call, resolve_template_literal, + QUERY_APIS, R3InputMetadata, StringConsts, evaluate_string_value, initializer_api_call, try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, }; use crate::output::ast::{ @@ -55,11 +55,11 @@ use crate::util::lone_surrogate::js_str_to_ident; /// upstream's `if (dec.name !== 'Component') return dec;`. pub fn build_decorator_metadata_array<'a>( allocator: &'a Allocator, - decorators: &[&Decorator<'a>], + decorators: &[&'a Decorator<'a>], source_text: Option<&'a str>, inlined_template: Option<&'a str>, inlined_styles: Option<&[Ident<'a>]>, - component_decorator: Option<&Decorator<'a>>, + component_decorator: Option<&'a Decorator<'a>>, consts: Option<&StringConsts<'a>>, ) -> OutputExpression<'a> { // Whether `transformDecoratorResources` rewrites the `args` of decorators @@ -339,7 +339,7 @@ fn inline_component_resources<'a>( fn drop_unresolvable_template_literal_fields<'a>( allocator: &'a Allocator, converted: &mut OutputExpression<'a>, - source: &Expression<'a>, + source: &'a Expression<'a>, consts: &StringConsts<'a>, ) { let Expression::ObjectExpression(obj) = source else { @@ -360,7 +360,10 @@ fn drop_unresolvable_template_literal_fields<'a>( if tpl.expressions.is_empty() { continue; } - if resolve_template_literal(allocator, tpl, consts).is_some() { + // Read the value the way the compile path reads `template`: the + // const folder first, then the partial evaluator (`${WIDTH}px` with + // `const WIDTH = 150`, `${A + B}`, an imported const, ...). + if evaluate_string_value(allocator, &prop.value, consts).is_some() { continue; // Resolvable — leave the converted value as-is. } if let Some(key) = get_property_key_name(&prop.key) { @@ -395,7 +398,7 @@ fn build_template_entry<'a>(allocator: &'a Allocator, content: &'a str) -> Liter /// [`build_ctor_params_metadata_in`] recognises only Angular's, like ngtsc. pub fn build_ctor_params_metadata<'a>( allocator: &'a Allocator, - class: &Class<'a>, + class: &'a Class<'a>, constructor_deps: Option<&[R3DependencyMetadata<'a>]>, namespace_registry: &mut NamespaceRegistry<'a>, import_map: &ImportMap<'a>, @@ -426,7 +429,7 @@ pub fn build_ctor_params_metadata<'a>( /// `decorators: []`. pub fn build_ctor_params_metadata_in<'a>( allocator: &'a Allocator, - class: &Class<'a>, + class: &'a Class<'a>, constructor_deps: Option<&[R3DependencyMetadata<'a>]>, namespace_registry: &mut NamespaceRegistry<'a>, import_map: &ImportMap<'a>, @@ -537,7 +540,7 @@ pub fn build_ctor_params_metadata_in<'a>( /// only Angular's, like ngtsc. pub fn build_prop_decorators_metadata<'a>( allocator: &'a Allocator, - class: &Class<'a>, + class: &'a Class<'a>, source_text: Option<&'a str>, namespace_registry: &mut NamespaceRegistry<'a>, ) -> Option> { @@ -554,7 +557,7 @@ pub fn build_prop_decorators_metadata<'a>( /// same way (`outputFromObservable` from `@angular/core/rxjs-interop`). pub fn build_prop_decorators_metadata_in<'a>( allocator: &'a Allocator, - class: &Class<'a>, + class: &'a Class<'a>, source_text: Option<&'a str>, namespace_registry: &mut NamespaceRegistry<'a>, consts: Option<&StringConsts<'a>>, diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index cfaadd807..2a617d1ea 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -86,7 +86,7 @@ pub fn extract_component_metadata<'a>( match key_name.as_str() { "selector" => { metadata.selector = - crate::directive::extract_string_value(allocator, &prop.value, consts) + crate::directive::evaluate_string_value(allocator, &prop.value, consts) // ngtsc maps `selector: ''` to the default selector // ('ng-component' for components), same as a missing // selector. See annotations/directive/src/shared.ts @@ -195,7 +195,7 @@ pub fn extract_component_metadata<'a>( "exportAs" => { // exportAs can be comma-separated: "foo, bar" if let Some(export_as) = - crate::directive::extract_string_value(allocator, &prop.value, consts) + crate::directive::evaluate_string_value(allocator, &prop.value, consts) { for part in export_as.as_str().split(',') { let trimmed = part.trim(); diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 68d7e7c03..ea9912466 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -853,7 +853,7 @@ fn find_last_import_end(program_body: &[Statement<'_>]) -> Option { #[allow(clippy::too_many_arguments)] fn build_set_class_metadata_decls<'a>( allocator: &'a Allocator, - class: &oxc_ast::ast::Class<'a>, + class: &'a oxc_ast::ast::Class<'a>, class_name: &str, options: &TransformOptions, source: &'a str, diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 5de4d38fb..72502a9a0 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -14,8 +14,6 @@ use oxc_ast::ast::{ use oxc_diagnostics::OxcDiagnostic; use oxc_span::{GetSpan, Span}; use oxc_str::Ident; -use oxc_syntax::number::ToJsString; -use oxc_syntax::operator::UnaryOperator; use super::evaluator::{Evaluator, FileScope, Prop, RefKind, Value, transform_error}; use super::metadata::{ @@ -145,7 +143,8 @@ pub fn extract_directive_metadata<'a>( match key_name.as_str() { "selector" => { - if let Some(selector) = extract_string_value(allocator, &prop.value, consts) + if let Some(selector) = + evaluate_string_value(allocator, &prop.value, consts) { builder = builder.selector(selector); } @@ -157,7 +156,7 @@ pub fn extract_directive_metadata<'a>( } "exportAs" => { if let Some(export_as) = - extract_string_value(allocator, &prop.value, consts) + evaluate_string_value(allocator, &prop.value, consts) { // exportAs can be comma-separated: "foo, bar" for part in export_as.as_str().split(',') { @@ -492,8 +491,6 @@ fn has_ng_on_changes_method(class: &Class<'_>) -> bool { #[derive(Default)] pub struct StringConsts<'a> { strings: HashMap<&'a str, Ident<'a>>, - /// `const NAME = 123`: numbers a template literal's `${...}` can interpolate. - numbers: HashMap<&'a str, f64>, program: Option<&'a Program<'a>>, /// Built on first use: most files have no decorator metadata to evaluate. scope: std::cell::OnceCell>, @@ -539,7 +536,6 @@ impl<'a> StringConsts<'a> { pub(crate) fn declarations_of(program: &'a Program<'a>) -> Self { Self { strings: HashMap::default(), - numbers: HashMap::default(), program: Some(program), scope: std::cell::OnceCell::new(), #[cfg(feature = "cross_file_elision")] @@ -599,10 +595,6 @@ pub fn collect_string_consts<'a>( let mut map = StringConsts { strings: HashMap::default(), - numbers: pending - .iter() - .filter_map(|(name, init)| Some((*name, numeric_literal_value(init)?))) - .collect(), program: Some(program), scope: std::cell::OnceCell::new(), #[cfg(feature = "cross_file_elision")] @@ -641,23 +633,9 @@ fn literal_string_from_expression<'a>(expr: &Expression<'a>) -> Option } } -/// The value of a number literal (`150`, `-1.5`). -fn numeric_literal_value(expr: &Expression<'_>) -> Option { - match expr { - Expression::NumericLiteral(lit) => Some(lit.value), - Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => { - match &unary.argument { - Expression::NumericLiteral(lit) => Some(-lit.value), - _ => None, - } - } - _ => None, - } -} - /// Fold a template literal whose `${...}` interpolations reference known -/// string consts, number consts or number literals. Returns `None` if any -/// interpolation can't be statically resolved to a string — matching Angular's all-or-nothing partial evaluator +/// string consts. Returns `None` if any interpolation can't be statically +/// resolved to a string — matching Angular's all-or-nothing partial evaluator /// for static metadata fields. pub(crate) fn resolve_template_literal<'a>( allocator: &'a Allocator, @@ -674,17 +652,8 @@ pub(crate) fn resolve_template_literal<'a>( let cooked = quasi.value.cooked?.as_str()?; buf.push_str(cooked); if let Some(expr) = tpl.expressions.get(i) { - if let Some(resolved) = extract_string_value(allocator, expr, consts) { - buf.push_str(resolved.as_str()); - } else { - // `${150}` or `${WIDTH}` with `const WIDTH = 150`: the number as - // JavaScript converts it to a string - let number = match expr { - Expression::Identifier(id) => *consts.numbers.get(id.name.as_str())?, - _ => numeric_literal_value(expr)?, - }; - buf.push_str(&number.to_js_string()); - } + let resolved = extract_string_value(allocator, expr, consts)?; + buf.push_str(resolved.as_str()); } } Some(Ident::from(allocator.alloc_str(&buf))) diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 35a483435..7cdb5466e 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -30,8 +30,7 @@ pub(crate) use decorator::evaluate_host_directives; pub(crate) use decorator::find_directive_decorator; pub(crate) use decorator::{ HostKey, angular_decorator_config, evaluate_host_metadata, evaluate_string_value, - evaluate_style_urls, evaluate_styles, extract_string_value, resolve_template_literal, - styles_error, + evaluate_style_urls, evaluate_styles, extract_string_value, styles_error, }; pub(crate) use decorator::{ INPUT_API, InitializerApi, MODEL_API, OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API, QUERY_APIS, diff --git a/crates/oxc_angular_compiler/tests/template_literal_number_const_test.rs b/crates/oxc_angular_compiler/tests/template_literal_number_const_test.rs index afbe7495b..957a82891 100644 --- a/crates/oxc_angular_compiler/tests/template_literal_number_const_test.rs +++ b/crates/oxc_angular_compiler/tests/template_literal_number_const_test.rs @@ -55,3 +55,23 @@ export class E3Cmp {} "Got:\n{code}" ); } + +#[test] +fn evaluator_only_template_literals_keep_their_fields() { + // `${W * 2}` (with `W` an alias of another const) folds only through the + // partial evaluator. ngtsc keeps the fields as written in setClassMetadata + // and uses the folded selector. + let code = compile( + r#" +import { Component } from '@angular/core'; +const BASE = 150; +const W = BASE; +@Component({ selector: `x-${W * 2}`, exportAs: `e${+W}`, template: `` }) +export class C {} +"#, + ); + assert!(code.contains(r#"selectors:[["x-300"]]"#), "Got:\n{code}"); + assert!(code.contains(r#"exportAs:["e150"]"#), "Got:\n{code}"); + assert!(code.contains("selector:`x-${"), "Got:\n{code}"); + assert!(code.contains("template:` Date: Thu, 22 Oct 2026 23:42:46 +0800 Subject: [PATCH 2/2] fix(jit): read resource URLs with the partial evaluator The JIT rewrite of `templateUrl`, `styleUrl` and `styleUrls` reads the URL with `evaluate_string_value` too, so a number interpolation (`./v${1}.html`, a number const, `${V * 2}`) still folds now that the narrower number map is gone, and JIT accepts the same shapes as AOT. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/transform.rs | 20 +++++----- .../tests/integration_test.rs | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index ea9912466..9bca18f5e 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -1994,7 +1994,7 @@ fn build_ctor_parameters_text(params: &[JitCtorParam]) -> Option { fn build_jit_decorator_text<'a>( allocator: &'a Allocator, source: &str, - decorator: &oxc_ast::ast::Decorator<'a>, + decorator: &'a oxc_ast::ast::Decorator<'a>, decorator_kind: AngularDecoratorKind, resource_counter: &mut u32, resource_imports: &mut std::vec::Vec<(String, String)>, // (import_name, specifier) @@ -2035,11 +2035,11 @@ fn build_jit_decorator_text<'a>( match key_name { Some("templateUrl") => { - // Resolve the URL through the same const/template-literal - // folder used by AOT extraction so JIT and AOT accept the - // same metadata shapes. + // Resolve the URL the way AOT extraction reads it (the + // const folder, then the partial evaluator) so JIT and AOT + // accept the same metadata shapes. if let Some(url) = - crate::directive::extract_string_value(allocator, &prop.value, consts) + crate::directive::evaluate_string_value(allocator, &prop.value, consts) { let import_name = format!("__NG_CLI_RESOURCE__{}", *resource_counter); let specifier = format!("angular:jit:template:file;{}", url.as_str()); @@ -2056,7 +2056,7 @@ fn build_jit_decorator_text<'a>( Some("styleUrl") => { // Single style URL if let Some(url) = - crate::directive::extract_string_value(allocator, &prop.value, consts) + crate::directive::evaluate_string_value(allocator, &prop.value, consts) { let import_name = format!("__NG_CLI_RESOURCE__{}", *resource_counter); let specifier = format!("angular:jit:style:file;{}", url.as_str()); @@ -2080,9 +2080,9 @@ fn build_jit_decorator_text<'a>( // through `as_expression()` and let the resolver // decide which shapes fold. let Some(elem_expr) = elem.as_expression() else { continue }; - if let Some(url) = - crate::directive::extract_string_value(allocator, elem_expr, consts) - { + if let Some(url) = crate::directive::evaluate_string_value( + allocator, elem_expr, consts, + ) { let import_name = format!("__NG_CLI_RESOURCE__{}", *resource_counter); let specifier = format!("angular:jit:style:file;{}", url.as_str()); @@ -2547,7 +2547,7 @@ fn synthetic_default_name(taken: &mut rustc_hash::FxHashSet) -> String { fn collect_jit_class_info<'a>( allocator: &'a Allocator, source: &str, - class: &oxc_ast::ast::Class<'a>, + class: &'a oxc_ast::ast::Class<'a>, class_name: String, stmt_start: u32, is_exported: bool, diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index b594d9bcf..a62d46fd3 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -12808,6 +12808,45 @@ export class AppComponent {} ); } +/// JIT mode: a resource URL interpolating a number (`${1}`, a number const or +/// an expression of one) folds through the partial evaluator, like in AOT. +#[test] +fn jit_resource_urls_with_number_interpolation() { + let allocator = Allocator::default(); + let source = r#" +import { Component } from '@angular/core'; + +const V = 2; + +@Component({ + selector: 'app-root', + templateUrl: `./v${1}.html`, + styleUrl: `./v${V}.css`, + standalone: true, +}) +export class AppComponent {} + +@Component({ + selector: 'app-other', + template: '

x

', + styleUrls: [`./w${V * 2}.css`], +}) +export class OtherComponent {} +"#; + let options = ComponentTransformOptions { jit: true, ..Default::default() }; + let result = + transform_angular_file(&allocator, "app.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + + for specifier in [ + "angular:jit:template:file;./v1.html", + "angular:jit:style:file;./v2.css", + "angular:jit:style:file;./w4.css", + ] { + assert!(result.code.contains(specifier), "Expected `{specifier}`.\nCode:\n{}", result.code); + } +} + /// JIT mode: `templateUrl` may be a bare identifier referencing a same-file /// string `const`. Must also fold. #[test]