From 7e7b8cf287d1fe73ce69711c36e143382ab041f6 Mon Sep 17 00:00:00 2001 From: Damian Kaczmarek Date: Wed, 21 Oct 2026 13:08:26 -0500 Subject: [PATCH] fix(metadata): fold number consts in a template literal's interpolations `template: \`\`` with `const WIDTH = 150` left the component uncompiled: only string consts were folded (#286). Like ngtsc's partial evaluator, a `${...}` interpolation can now also be a number literal or a same-file number const (`-2` included), converted as JavaScript converts numbers to strings. --- .../src/directive/decorator.rs | 40 +++++++++++-- .../template_literal_number_const_test.rs | 57 +++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/template_literal_number_const_test.rs diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 482884037..3d1b058a3 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -14,6 +14,8 @@ 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::{ @@ -512,6 +514,8 @@ 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>, @@ -557,6 +561,7 @@ 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")] @@ -616,6 +621,10 @@ 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")] @@ -654,9 +663,23 @@ 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. Returns `None` if any interpolation can't be statically -/// resolved to a string — matching Angular's all-or-nothing partial evaluator +/// 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 /// for static metadata fields. pub(crate) fn resolve_template_literal<'a>( allocator: &'a Allocator, @@ -673,8 +696,17 @@ 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) { - let resolved = extract_string_value(allocator, expr, consts)?; - buf.push_str(resolved.as_str()); + 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()); + } } } Some(Ident::from(allocator.alloc_str(&buf))) 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 new file mode 100644 index 000000000..afbe7495b --- /dev/null +++ b/crates/oxc_angular_compiler/tests/template_literal_number_const_test.rs @@ -0,0 +1,57 @@ +//! A `${...}` interpolation in an inline template can be a number, a literal or +//! a same-file `const` (`const WIDTH = 150`), as well as a string: ngtsc's +//! partial evaluator folds it as JavaScript converts the number to a string. +//! Before, the component was left uncompiled. The expected `consts` are +//! `@angular/compiler-cli` 21.0.1's for the same components. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +fn compile(source: &str) -> String { + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "test.ts", + source, + Some(&TransformOptions::default()), + None, + ); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + result.code.chars().filter(|c| !c.is_whitespace()).collect() +} + +#[test] +fn number_consts_in_a_template_literal() { + let code = compile( + r#" +import { Component } from '@angular/core'; +const WIDTH = 150; +const HEIGHT = 80; +const CLS = 'box'; +@Component({ selector: 'e-cmp', template: `` }) +export class ECmp {} +"#, + ); + assert!( + code.contains(r#"consts:[[1,"box",2,"width","150px","height","80px"]]"#), + "Got:\n{code}" + ); +} + +#[test] +fn chained_and_negative_number_consts_and_number_literals() { + let code = compile( + r#" +import { Component } from '@angular/core'; +const WIDTH = 150; +const SIZE = `${WIDTH}px`; +const OFFSET = -2; +@Component({ selector: 'e3-cmp', template: `
` }) +export class E3Cmp {} +"#, + ); + assert!( + code.contains(r#"consts:[[2,"width","150px","margin","-2px","opacity","0.5"]]"#), + "Got:\n{code}" + ); +}