From cff1f7cdd1daa82721e2e0d5c9306db943759709 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Tue, 5 May 2026 09:26:31 +0100 Subject: [PATCH 1/2] fix: handle TemplateLiteral in convert_ast_to_ir to register pipes and resolve @let variables TemplateLiteral was not handled in convert_ast_to_ir and fell through to store_and_ref_expr, so any BindingPipe inside was never registered with pipe_creation and @let variable reads were resolved against ctx instead of the local scope. Fix adds an explicit arm for AngularExpression::TemplateLiteral that recursively converts inner expressions via convert_ast_to_ir, producing IrExpression::ResolvedTemplateLiteral with properly converted children. Also adds ResolvedTemplateLiteral traversal to pipe_creation and resolve_names so pipes are discovered and names are resolved correctly. Confirmed output matches Angular 21.2.4 ngtsc for the same templates. --- .../src/pipeline/ingest.rs | 31 +++++++ .../src/pipeline/phases/pipe_creation.rs | 5 + .../src/pipeline/phases/resolve_names.rs | 8 ++ .../tests/integration_test.rs | 91 +++++++++++++++++++ ...test__template_literal_multiple_pipes.snap | 18 ++++ ...ate_literal_pipe_in_attribute_binding.snap | 11 +++ ...__template_literal_pipe_in_child_view.snap | 27 ++++++ ...tion_test__template_literal_with_pipe.snap | 14 +++ ...__template_literal_with_pipe_and_text.snap | 14 +++ ...n_test__template_literal_without_pipe.snap | 11 +++ napi/angular-compiler/test/transform.test.ts | 32 +++++++ 11 files changed, 262 insertions(+) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_multiple_pipes.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_attribute_binding.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_child_view.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe_and_text.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_without_pipe.snap diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index a6a318972..cf4cca0ff 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -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), } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/pipe_creation.rs b/crates/oxc_angular_compiler/src/pipeline/phases/pipe_creation.rs index 13d063468..7c9eedc02 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/pipe_creation.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/pipe_creation.rs @@ -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); + } + } _ => {} } } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs index 837a36f99..b594d9b05 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/resolve_names.rs @@ -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 _ => {} } diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 8d9763585..6974a7034 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -762,6 +762,97 @@ 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#"
"#, + "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 // ============================================================================ diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_multiple_pipes.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_multiple_pipes.snap new file mode 100644 index 000000000..d27b5d6c2 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_multiple_pipes.snap @@ -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)}`); + } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_attribute_binding.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_attribute_binding.snap new file mode 100644 index 000000000..4fc48d1e9 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_attribute_binding.snap @@ -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`); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_child_view.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_child_view.snap new file mode 100644 index 000000000..5bd12c76f --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_pipe_in_child_view.snap @@ -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)); + } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe.snap new file mode 100644 index 000000000..4a8b0470f --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe.snap @@ -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)}`); + } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe_and_text.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe_and_text.snap new file mode 100644 index 000000000..7e46f0e4b --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_with_pipe_and_text.snap @@ -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`); + } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_without_pipe.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_without_pipe.snap new file mode 100644 index 000000000..900983d74 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__template_literal_without_pipe.snap @@ -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}!`); + } +} diff --git a/napi/angular-compiler/test/transform.test.ts b/napi/angular-compiler/test/transform.test.ts index 721c81720..dd6e25a83 100644 --- a/napi/angular-compiler/test/transform.test.ts +++ b/napi/angular-compiler/test/transform.test.ts @@ -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', () => { From 6e42eabdc5a559faf832701f35af48a7aff1e684 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Tue, 5 May 2026 09:34:17 +0100 Subject: [PATCH 2/2] style: apply rustfmt to integration tests --- .../tests/integration_test.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 6974a7034..0ccb2b779 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -773,14 +773,8 @@ fn test_template_literal_with_pipe() { // 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}" - ); + 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); } @@ -801,10 +795,8 @@ fn test_template_literal_with_pipe_and_text() { #[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", - ); + let js = + compile_template_to_js(r"@let name = 'world'; {{ `Hello ${name}!` }}", "TestComponent"); insta::assert_snapshot!("template_literal_without_pipe", js); }