Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,37 @@ fn convert_ast_to_ir<'a>(
)
}

// Convert TemplateLiteral - recursively convert inner expressions to preserve pipes.
// Without this, template literals fall through to store_and_ref_expr, which stores
// the entire literal as a raw AST blob. Any BindingPipe inside is then invisible to
// the pipe_creation phase and any @let variable reads inside are never resolved.
AngularExpression::TemplateLiteral(tl) => {
let tl = tl.unbox();
let mut elements = Vec::with_capacity_in(tl.elements.len(), allocator);
for elem in tl.elements.iter() {
elements.push(crate::ir::expression::IrTemplateLiteralElement {
text: elem.text.clone(),
source_span: Some(elem.source_span.to_span()),
});
}
let mut expressions = Vec::with_capacity_in(tl.expressions.len(), allocator);
for expr in tl.expressions {
let converted = convert_ast_to_ir(job, expr);
expressions.push(converted.unbox());
}
Box::new_in(
IrExpression::ResolvedTemplateLiteral(Box::new_in(
crate::ir::expression::ResolvedTemplateLiteralExpr {
elements,
expressions,
source_span: Some(tl.source_span.to_span()),
},
allocator,
)),
allocator,
)
}

// For all other expressions, store in ExpressionStore and return reference.
other => store_and_ref_expr(job, other),
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,11 @@ fn collect_pipe_bindings<'a>(op: &crate::ir::ops::UpdateOp<'a>, bindings: &mut V
IrExpression::Parenthesized(paren) => {
check_expression(&paren.expr, target_element, bindings);
}
IrExpression::ResolvedTemplateLiteral(tl) => {
for expr in tl.expressions.iter() {
check_expression(expr, target_element, bindings);
}
}
_ => {}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,14 @@ fn resolve_expression<'a>(
);
}

// ResolvedTemplateLiteral (created by ingest for template literals with inner expressions)
// - resolve each inner expression so LexicalRead refs to @let vars and pipe args are resolved
IrExpression::ResolvedTemplateLiteral(tl) => {
for expr in tl.expressions.iter_mut() {
resolve_expression(expr, scope, root_xref, saved_view, allocator, expressions);
}
}

// Other expression types don't need resolution
_ => {}
}
Expand Down
83 changes: 83 additions & 0 deletions crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,89 @@ fn test_let_with_pipe_multiple_in_child_view_varoffset() {
insta::assert_snapshot!("let_with_pipe_multiple_in_child_view_varoffset", js);
}

// ============================================================================
// Template literal Tests
// ============================================================================

#[test]
fn test_template_literal_with_pipe() {
// {{ `${num | percent}` }} - template literal containing a pipe call on a @let variable.
// TemplateLiteral was not handled in convert_ast_to_ir and fell through to
// store_and_ref_expr, so the inner BindingPipe was never registered with
// pipe_creation and the @let variable was resolved against ctx instead of the
// local scope.
let js = compile_template_to_js(r"@let num = 0.75; {{ `${num | percent}` }}", "TestComponent");
assert!(js.contains("ɵɵpipeBind1"), "percent pipe should be registered. Output:\n{js}");
insta::assert_snapshot!("template_literal_with_pipe", js);
}

#[test]
fn test_template_literal_with_pipe_and_text() {
// Template literal with mixed text and pipe: `Value: ${num | percent} done`
let js = compile_template_to_js(
r"@let num = 0.75; {{ `Value: ${num | percent} done` }}",
"TestComponent",
);
assert!(
js.contains("ɵɵpipeBind1"),
"percent pipe should be registered in template literal with surrounding text. Output:\n{js}"
);
insta::assert_snapshot!("template_literal_with_pipe_and_text", js);
}

#[test]
fn test_template_literal_without_pipe() {
// Template literal without pipe should still work correctly (regression guard).
let js =
compile_template_to_js(r"@let name = 'world'; {{ `Hello ${name}!` }}", "TestComponent");
insta::assert_snapshot!("template_literal_without_pipe", js);
}

#[test]
fn test_template_literal_pipe_in_attribute_binding() {
// Template literal with pipe used as an attribute binding value.
// Real-world pattern: [label]="`${(count() | number)}`"
// Before the fix the pipe was silently dropped, producing `${ctx.count()}` instead.
let js = compile_template_to_js(
r#"<div [title]="`${count() | number} items`"></div>"#,
"TestComponent",
);
assert!(
js.contains("ɵɵpipeBind1"),
"number pipe should appear in attribute binding template literal. Output:\n{js}"
);
insta::assert_snapshot!("template_literal_pipe_in_attribute_binding", js);
}

#[test]
fn test_template_literal_multiple_pipes() {
// Two pipes inside one template literal. Both must be registered.
let js = compile_template_to_js(
r"@let a = 0.5; @let b = 1234; {{ `${a | percent} of ${b | number}` }}",
"TestComponent",
);
assert!(
js.matches("ɵɵpipeBind1").count() >= 2,
"both percent and number pipes should appear. Output:\n{js}"
);
insta::assert_snapshot!("template_literal_multiple_pipes", js);
}

#[test]
fn test_template_literal_pipe_in_child_view() {
// Template literal + pipe inside an @if child view.
// Pipe must be registered in the child view's create block.
let js = compile_template_to_js(
r"@let n = 0.75; @if (true) { {{ `${n | percent}` }} }",
"TestComponent",
);
assert!(
js.contains("ɵɵpipeBind1"),
"percent pipe should be registered in child view template literal. Output:\n{js}"
);
insta::assert_snapshot!("template_literal_pipe_in_child_view", js);
}

// ============================================================================
// @let self-reference / forward-reference Tests
// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: js
---
function TestComponent_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵtext(0," ");
i0.ɵɵtext(1);
i0.ɵɵpipe(2,"percent");
i0.ɵɵpipe(3,"number");
}
if ((rf & 2)) {
const a_r1 = 0.5;
const b_r2 = 1234;
i0.ɵɵadvance();
i0.ɵɵtextInterpolate1(" ",`${i0.ɵɵpipeBind1(2,1,a_r1)} of ${i0.ɵɵpipeBind1(3,3,b_r2)}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: js
---
function TestComponent_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵelement(0,"div",0);
i0.ɵɵpipe(1,"number");
}
if ((rf & 2)) { i0.ɵɵproperty("title",`${i0.ɵɵpipeBind1(1,1,ctx.count())} items`); }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: js
---
function TestComponent_Conditional_2_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵtext(0);
i0.ɵɵpipe(1,"percent");
}
if ((rf & 2)) {
i0.ɵɵnextContext();
const n_r1 = i0.ɵɵreadContextLet(0);
i0.ɵɵtextInterpolate1(" ",`${i0.ɵɵpipeBind1(1,1,n_r1)}`," ");
}
}
function TestComponent_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵdeclareLet(0);
i0.ɵɵtext(1," ");
i0.ɵɵconditionalCreate(2,TestComponent_Conditional_2_Template,2,3);
}
if ((rf & 2)) {
i0.ɵɵstoreLet(0.75);
i0.ɵɵadvance(2);
i0.ɵɵconditional((true? 2: -1));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: js
---
function TestComponent_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵtext(0);
i0.ɵɵpipe(1,"percent");
}
if ((rf & 2)) {
const num_r1 = 0.75;
i0.ɵɵtextInterpolate1(" ",`${i0.ɵɵpipeBind1(1,1,num_r1)}`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: js
---
function TestComponent_Template(rf,ctx) {
if ((rf & 1)) {
i0.ɵɵtext(0);
i0.ɵɵpipe(1,"percent");
}
if ((rf & 2)) {
const num_r1 = 0.75;
i0.ɵɵtextInterpolate1(" ",`Value: ${i0.ɵɵpipeBind1(1,1,num_r1)} done`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
source: crates/oxc_angular_compiler/tests/integration_test.rs
expression: js
---
function TestComponent_Template(rf,ctx) {
if ((rf & 1)) { i0.ɵɵtext(0); }
if ((rf & 2)) {
const name_r1 = "world";
i0.ɵɵtextInterpolate1(" ",`Hello ${name_r1}!`);
}
}
32 changes: 32 additions & 0 deletions napi/angular-compiler/test/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,38 @@ describe('compileTemplateSync', () => {
expect(result.code).toContain('function')
expect(result.code).toContain('TestComponent_Template')
})

it('should compile template literal with pipe inside interpolation (CX-40791)', async () => {
// TemplateLiteral was not handled in convert_ast_to_ir and fell through to
// store_and_ref_expr, so the inner BindingPipe was never registered with
// pipe_creation and the @let variable was resolved against ctx instead of the
// local scope.
const result = await compileTemplate(
'@let num = 0.75; {{ `${num | percent}` }}',
'TestComponent',
'test.ts',
)

expect(result.errors).toHaveLength(0)
// Pipe must be registered in the create block
expect(result.code).toContain('ɵɵpipe')
// pipeBind1 must be called in the update block
expect(result.code).toContain('ɵɵpipeBind1')
// @let variable must be stored
expect(result.code).toContain('0.75')
})

it('should compile template literal with surrounding text and pipe (CX-40791)', async () => {
const result = await compileTemplate(
'@let num = 0.75; {{ `Value: ${num | percent} done` }}',
'TestComponent',
'test.ts',
)

expect(result.errors).toHaveLength(0)
expect(result.code).toContain('ɵɵpipe')
expect(result.code).toContain('ɵɵpipeBind1')
})
})

describe('extractAngularComponentByAst', () => {
Expand Down
Loading