From e1c49e9caed298a0e7100539fae59b9852273b63 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Fri, 2 Oct 2026 11:29:10 +0100 Subject: [PATCH] fix(linker): use the file's @angular/core namespace in generated templates Minifiers rename the `import * as i0 from "@angular/core"` namespace (e.g. to `e`). The linker already used that name in its own output, but the template and host-binding pipeline hardcodes `i0`, so linked components and directives referenced an unbound `i0` and threw `ReferenceError: i0 is not defined`. The emitter can now print the file's namespace in place of `i0`, and the linker sets it for template and host-binding output, matching Angular's Babel linker. --- .../src/component/transform.rs | 6 +++-- crates/oxc_angular_compiler/src/linker/mod.rs | 23 +++++++++++++++++++ .../src/output/emitter.rs | 16 +++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 767a4428c..97d659c43 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3542,6 +3542,7 @@ pub fn compile_host_bindings_for_linker( component_name: &str, selector: Option<&str>, pool_starting_index: u32, + core_namespace: &str, ) -> Option { let allocator = Allocator::default(); let result = compile_host_bindings_from_input( @@ -3553,7 +3554,7 @@ pub fn compile_host_bindings_for_linker( None, // Linker always targets latest Angular version )?; - let emitter = JsEmitter::new(); + let emitter = JsEmitter::with_core_namespace(core_namespace); let host_vars = result.host_vars.unwrap_or(0); @@ -3613,6 +3614,7 @@ pub fn compile_template_for_linker<'a>( component_name: &str, file_path: &str, preserve_whitespaces: bool, + core_namespace: &str, ) -> Result> { use crate::pipeline::ingest::{IngestOptions, ingest_component_with_options}; use oxc_allocator::FromIn; @@ -3689,7 +3691,7 @@ pub fn compile_template_for_linker<'a>( let decls = job.root.decl_count.unwrap_or(0); let vars = job.root.vars.unwrap_or(0); - let emitter = JsEmitter::new(); + let emitter = JsEmitter::with_core_namespace(core_namespace); // Emit consts array as JS expression let consts_js = if !job.consts.is_empty() { diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 3c63ff805..33e9347b0 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -1394,6 +1394,7 @@ fn link_directive( type_name, selector, 0, // directives have no template, so pool starts at 0 + ns, ) { if host_output.host_vars > 0 { parts.push(format!("hostVars: {}", host_output.host_vars)); @@ -1706,6 +1707,7 @@ fn link_component( type_name, filename, preserve_whitespaces, + ns, ) .ok()?; @@ -1753,6 +1755,7 @@ fn link_component( type_name, selector, template_output.next_pool_index, + ns, ) { if host_output.host_vars > 0 { parts.push(format!("hostVars: {}", host_output.host_vars)); @@ -2492,6 +2495,26 @@ MyComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: " ); } + /// Minifiers rename the `@angular/core` namespace import, so generated code must use + /// the file's namespace rather than `i0`. + #[test] + fn test_link_component_uses_minified_namespace() { + let allocator = Allocator::default(); + let code = r#" +import * as e from "@angular/core"; +class C { +} +class D { +} +D.ɵdir = e.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "20.0.0", ngImport: e, type: D, selector: "[d]", host: { properties: { "class.on": "on" } } }); +C.ɵcmp = e.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.0.0", ngImport: e, type: C, selector: "c", host: { properties: { "class.on": "on" }, listeners: { "click": "go()" } }, template: "
{{ a | uppercase }} {{ [a, b] }}
@if (a) {Hi {{ a }}} @defer {}" }); +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked, "Component should be linked"); + assert!(result.code.contains("e.\u{0275}\u{0275}elementStart"), "got:\n{}", result.code); + assert!(!result.code.contains("i0"), "Should not reference i0, got:\n{}", result.code); + } + #[test] fn test_link_component_with_factory() { let allocator = Allocator::default(); diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index 350b2ec4b..c5eb037f6 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -368,12 +368,20 @@ fn get_source_span(expr: &OutputExpression<'_>) -> Option { pub struct JsEmitter { /// Whether to escape $ in strings. escape_dollar_in_strings: bool, + /// Name to print instead of `i0` for the `@angular/core` namespace. + core_namespace: Option, } impl JsEmitter { /// Create a new JavaScript emitter. pub fn new() -> Self { - Self { escape_dollar_in_strings: false } + Self { escape_dollar_in_strings: false, core_namespace: None } + } + + /// Print `ns` instead of `i0` for the `@angular/core` namespace. The linker needs this + /// because a minified library may import `@angular/core` under any name. + pub fn with_core_namespace(ns: &str) -> Self { + Self { escape_dollar_in_strings: false, core_namespace: Some(ns.to_string()) } } /// Emit an expression to a string. @@ -642,7 +650,11 @@ impl JsEmitter { OutputExpression::ReadVar(e) => { // Variable references are key for source mapping - map the variable name let var_span = e.source_span.and_then(|span| ctx.span_to_source_span(span)); - ctx.print_with_span(&e.name, var_span); + let name = match &self.core_namespace { + Some(ns) if e.name == "i0" => ns.as_str(), + _ => e.name.as_str(), + }; + ctx.print_with_span(name, var_span); } OutputExpression::ReadProp(e) => { self.visit_expression(&e.receiver, ctx);