Skip to content

Commit c63f910

Browse files
authored
fix(metadata): fold number consts in a template literal's interpolations (#603)
`template: \`<canvas style="width: ${WIDTH}px"></canvas>\`` 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.
1 parent 1eed501 commit c63f910

2 files changed

Lines changed: 93 additions & 4 deletions

File tree

‎crates/oxc_angular_compiler/src/directive/decorator.rs‎

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use oxc_ast::ast::{
1414
use oxc_diagnostics::OxcDiagnostic;
1515
use oxc_span::{GetSpan, Span};
1616
use oxc_str::Ident;
17+
use oxc_syntax::number::ToJsString;
18+
use oxc_syntax::operator::UnaryOperator;
1719

1820
use super::evaluator::{Evaluator, FileScope, Prop, RefKind, Value, transform_error};
1921
use super::metadata::{
@@ -490,6 +492,8 @@ fn has_ng_on_changes_method(class: &Class<'_>) -> bool {
490492
#[derive(Default)]
491493
pub struct StringConsts<'a> {
492494
strings: HashMap<&'a str, Ident<'a>>,
495+
/// `const NAME = 123`: numbers a template literal's `${...}` can interpolate.
496+
numbers: HashMap<&'a str, f64>,
493497
program: Option<&'a Program<'a>>,
494498
/// Built on first use: most files have no decorator metadata to evaluate.
495499
scope: std::cell::OnceCell<FileScope<'a>>,
@@ -535,6 +539,7 @@ impl<'a> StringConsts<'a> {
535539
pub(crate) fn declarations_of(program: &'a Program<'a>) -> Self {
536540
Self {
537541
strings: HashMap::default(),
542+
numbers: HashMap::default(),
538543
program: Some(program),
539544
scope: std::cell::OnceCell::new(),
540545
#[cfg(feature = "cross_file_elision")]
@@ -594,6 +599,10 @@ pub fn collect_string_consts<'a>(
594599

595600
let mut map = StringConsts {
596601
strings: HashMap::default(),
602+
numbers: pending
603+
.iter()
604+
.filter_map(|(name, init)| Some((*name, numeric_literal_value(init)?)))
605+
.collect(),
597606
program: Some(program),
598607
scope: std::cell::OnceCell::new(),
599608
#[cfg(feature = "cross_file_elision")]
@@ -632,9 +641,23 @@ fn literal_string_from_expression<'a>(expr: &Expression<'a>) -> Option<Ident<'a>
632641
}
633642
}
634643

644+
/// The value of a number literal (`150`, `-1.5`).
645+
fn numeric_literal_value(expr: &Expression<'_>) -> Option<f64> {
646+
match expr {
647+
Expression::NumericLiteral(lit) => Some(lit.value),
648+
Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
649+
match &unary.argument {
650+
Expression::NumericLiteral(lit) => Some(-lit.value),
651+
_ => None,
652+
}
653+
}
654+
_ => None,
655+
}
656+
}
657+
635658
/// Fold a template literal whose `${...}` interpolations reference known
636-
/// string consts. Returns `None` if any interpolation can't be statically
637-
/// resolved to a string — matching Angular's all-or-nothing partial evaluator
659+
/// string consts, number consts or number literals. Returns `None` if any
660+
/// interpolation can't be statically resolved to a string — matching Angular's all-or-nothing partial evaluator
638661
/// for static metadata fields.
639662
pub(crate) fn resolve_template_literal<'a>(
640663
allocator: &'a Allocator,
@@ -651,8 +674,17 @@ pub(crate) fn resolve_template_literal<'a>(
651674
let cooked = quasi.value.cooked?.as_str()?;
652675
buf.push_str(cooked);
653676
if let Some(expr) = tpl.expressions.get(i) {
654-
let resolved = extract_string_value(allocator, expr, consts)?;
655-
buf.push_str(resolved.as_str());
677+
if let Some(resolved) = extract_string_value(allocator, expr, consts) {
678+
buf.push_str(resolved.as_str());
679+
} else {
680+
// `${150}` or `${WIDTH}` with `const WIDTH = 150`: the number as
681+
// JavaScript converts it to a string
682+
let number = match expr {
683+
Expression::Identifier(id) => *consts.numbers.get(id.name.as_str())?,
684+
_ => numeric_literal_value(expr)?,
685+
};
686+
buf.push_str(&number.to_js_string());
687+
}
656688
}
657689
}
658690
Some(Ident::from(allocator.alloc_str(&buf)))
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//! A `${...}` interpolation in an inline template can be a number, a literal or
2+
//! a same-file `const` (`const WIDTH = 150`), as well as a string: ngtsc's
3+
//! partial evaluator folds it as JavaScript converts the number to a string.
4+
//! Before, the component was left uncompiled. The expected `consts` are
5+
//! `@angular/compiler-cli` 21.0.1's for the same components.
6+
7+
use oxc_allocator::Allocator;
8+
use oxc_angular_compiler::{TransformOptions, transform_angular_file};
9+
10+
fn compile(source: &str) -> String {
11+
let allocator = Allocator::default();
12+
let result = transform_angular_file(
13+
&allocator,
14+
"test.ts",
15+
source,
16+
Some(&TransformOptions::default()),
17+
None,
18+
);
19+
assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics);
20+
result.code.chars().filter(|c| !c.is_whitespace()).collect()
21+
}
22+
23+
#[test]
24+
fn number_consts_in_a_template_literal() {
25+
let code = compile(
26+
r#"
27+
import { Component } from '@angular/core';
28+
const WIDTH = 150;
29+
const HEIGHT = 80;
30+
const CLS = 'box';
31+
@Component({ selector: 'e-cmp', template: `<canvas class="${CLS}" style="width: ${WIDTH}px; height: ${HEIGHT}px;"></canvas>` })
32+
export class ECmp {}
33+
"#,
34+
);
35+
assert!(
36+
code.contains(r#"consts:[[1,"box",2,"width","150px","height","80px"]]"#),
37+
"Got:\n{code}"
38+
);
39+
}
40+
41+
#[test]
42+
fn chained_and_negative_number_consts_and_number_literals() {
43+
let code = compile(
44+
r#"
45+
import { Component } from '@angular/core';
46+
const WIDTH = 150;
47+
const SIZE = `${WIDTH}px`;
48+
const OFFSET = -2;
49+
@Component({ selector: 'e3-cmp', template: `<div style="width: ${SIZE}; margin: ${OFFSET}px; opacity: ${0.5};"></div>` })
50+
export class E3Cmp {}
51+
"#,
52+
);
53+
assert!(
54+
code.contains(r#"consts:[[2,"width","150px","margin","-2px","opacity","0.5"]]"#),
55+
"Got:\n{code}"
56+
);
57+
}

0 commit comments

Comments
 (0)