From be1d003342476fa945d4a3c9ab375e69b92642c0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Mar 2026 19:23:20 +0800 Subject: [PATCH] fix: version-gate Angular 19 runtime instructions (#107) Angular 19.2 runtime uses different instructions than Angular 20+: - Combined `propertyInterpolate*`/`attributeInterpolate*`/`stylePropInterpolate*`/ `styleMapInterpolate*`/`classMapInterpolate*` instead of nested `property(interpolate*())` calls - `hostProperty` instead of `domProperty` This adds `supports_value_interpolation()` (>= 20) and `supports_dom_property()` (>= 20) version gates so the compiler emits the correct instructions based on the target Angular version. Also refactors the chaining phase's CHAIN_COMPATIBILITY from LazyLock to a const fn match lookup, and fixes `reify_interpolation` to preserve trailing empty strings when extra positional args (sanitizer, namespace, unit) follow. Co-Authored-By: Claude Opus 4.6 --- .../src/component/metadata.rs | 17 + .../src/component/transform.rs | 20 +- .../src/pipeline/compilation.rs | 30 ++ .../src/pipeline/ingest.rs | 11 + .../src/pipeline/phases/chaining.rs | 314 ++++++++++++++---- .../src/pipeline/phases/reify/mod.rs | 270 ++++++++++++--- .../phases/reify/statements/bindings.rs | 191 ++++++++++- .../src/r3/identifiers.rs | 245 ++++++++++++++ crates/oxc_angular_compiler/src/r3/mod.rs | 6 +- .../tests/integration_test.rs | 306 +++++++++++++++++ ...__attribute_interpolation_angular_v19.snap | 11 + ...__class_map_interpolation_angular_v19.snap | 8 + ...t__property_interpolation_angular_v19.snap | 12 + ...ty_interpolation_angular_v19_multiple.snap | 12 + ...erty_interpolation_angular_v19_simple.snap | 12 + ...eton_interpolation_with_sanitizer_v19.snap | 8 + ...__style_map_interpolation_angular_v19.snap | 8 + ..._style_prop_interpolation_angular_v19.snap | 8 + ...p_interpolation_angular_v19_with_unit.snap | 8 + 19 files changed, 1388 insertions(+), 109 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__attribute_interpolation_angular_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__class_map_interpolation_angular_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_multiple.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_simple.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__property_singleton_interpolation_with_sanitizer_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__style_map_interpolation_angular_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19_with_unit.snap diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 99b179a11..62f6f84ed 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -45,6 +45,23 @@ impl AngularVersion { self.major >= 20 } + /// Check if this version supports standalone `ɵɵinterpolate*` instructions (v20.0.0+). + /// + /// Angular v20 introduced standalone `ɵɵinterpolate1`–`ɵɵinterpolateV` instructions + /// used as nested calls within `ɵɵproperty`/`ɵɵattribute`. Earlier versions use + /// combined `ɵɵpropertyInterpolate*`/`ɵɵattributeInterpolate*` instructions. + pub fn supports_value_interpolation(&self) -> bool { + self.major >= 20 + } + + /// Check if this version supports `ɵɵdomProperty` (v20.0.0+). + /// + /// Angular v20 introduced `ɵɵdomProperty` for host/DomOnly property bindings. + /// Earlier versions use `ɵɵhostProperty` instead. + pub fn supports_dom_property(&self) -> bool { + self.major >= 20 + } + /// Parse a version string like "19.0.0" or "19.0.0-rc.1". /// /// Returns `None` if the version string is invalid. diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index be73ee096..e497c8bdd 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -63,7 +63,7 @@ use crate::pipeline::emit::{ }; use crate::pipeline::ingest::{ HostBindingInput, IngestOptions, ingest_component, ingest_component_with_options, - ingest_host_binding, + ingest_host_binding_with_version, }; use crate::transform::HtmlToR3Transform; use crate::transform::html_to_r3::TransformOptions as R3TransformOptions; @@ -2423,8 +2423,12 @@ fn compile_component_full<'a>( // Pass the template pool's current index to ensure host binding constants // continue from where template compilation left off (avoiding duplicate names) let template_pool_index = job.pool.next_name_index(); - let host_binding_output = - compile_component_host_bindings(allocator, metadata, template_pool_index); + let host_binding_output = compile_component_host_bindings( + allocator, + metadata, + template_pool_index, + options.angular_version, + ); // Extract the result and update pool index if host bindings were compiled let (host_binding_result, host_binding_next_pool_index, host_binding_declarations) = @@ -2848,6 +2852,7 @@ pub fn compile_template_to_js_with_options<'a>( component_name, options.selector.as_deref(), host_pool_starting_index, + options.angular_version, ) { // Add host binding pool declarations (pure functions, etc.) for decl in host_result.declarations { @@ -3111,6 +3116,7 @@ fn compile_component_host_bindings<'a>( allocator: &'a Allocator, metadata: &ComponentMetadata<'a>, pool_starting_index: u32, + angular_version: Option, ) -> Option> { let host = metadata.host.as_ref()?; @@ -3134,7 +3140,8 @@ fn compile_component_host_bindings<'a>( // Ingest and compile the host bindings with the pool starting index // This ensures constant names continue from where template compilation left off - let mut job = ingest_host_binding(allocator, input, pool_starting_index); + let mut job = + ingest_host_binding_with_version(allocator, input, pool_starting_index, angular_version); let result = compile_host_bindings(&mut job); // Get the next pool index after host binding compilation @@ -3411,6 +3418,7 @@ fn compile_host_bindings_from_input<'a>( component_name: &str, selector: Option<&str>, pool_starting_index: u32, + angular_version: Option, ) -> Option> { use oxc_allocator::FromIn; @@ -3436,7 +3444,8 @@ fn compile_host_bindings_from_input<'a>( // Convert to HostBindingInput and compile let input = convert_host_metadata_to_input(allocator, &host, component_name_atom, component_selector); - let mut job = ingest_host_binding(allocator, input, pool_starting_index); + let mut job = + ingest_host_binding_with_version(allocator, input, pool_starting_index, angular_version); let result = compile_host_bindings(&mut job); Some(result) @@ -3471,6 +3480,7 @@ pub fn compile_host_bindings_for_linker( component_name, selector, pool_starting_index, + None, // Linker always targets latest Angular version )?; let emitter = JsEmitter::new(); diff --git a/crates/oxc_angular_compiler/src/pipeline/compilation.rs b/crates/oxc_angular_compiler/src/pipeline/compilation.rs index 759b305df..99b3e3354 100644 --- a/crates/oxc_angular_compiler/src/pipeline/compilation.rs +++ b/crates/oxc_angular_compiler/src/pipeline/compilation.rs @@ -261,6 +261,23 @@ impl<'a> ComponentCompilationJob<'a> { self.angular_version.map_or(true, |v: AngularVersion| v.supports_conditional_create()) } + /// Check if standalone `ɵɵinterpolate*` instructions are supported (Angular 20+). + /// + /// Returns `true` for Angular 20+ or when version is unknown (None = latest). + /// Returns `false` for Angular 19 and earlier, which use combined + /// `ɵɵpropertyInterpolate*`/`ɵɵattributeInterpolate*` instructions. + pub fn supports_value_interpolation(&self) -> bool { + self.angular_version.map_or(true, |v: AngularVersion| v.supports_value_interpolation()) + } + + /// Check if `ɵɵdomProperty` is supported (Angular 20+). + /// + /// Returns `true` for Angular 20+ or when version is unknown (None = latest). + /// Returns `false` for Angular 19 and earlier, which use `ɵɵhostProperty` instead. + pub fn supports_dom_property(&self) -> bool { + self.angular_version.map_or(true, |v: AngularVersion| v.supports_dom_property()) + } + /// Allocates a new cross-reference ID. pub fn allocate_xref_id(&mut self) -> XrefId { let id = XrefId::new(self.next_xref_id); @@ -601,6 +618,8 @@ pub struct HostBindingCompilationJob<'a> { pub fn_suffix: Atom<'a>, /// Diagnostics collected during compilation. pub diagnostics: std::vec::Vec, + /// Angular version for version-gated instruction emission. + pub angular_version: Option, } impl<'a> HostBindingCompilationJob<'a> { @@ -646,6 +665,7 @@ impl<'a> HostBindingCompilationJob<'a> { mode: TemplateCompilationMode::DomOnly, // Host bindings always use DomOnly fn_suffix: Atom::from("HostBindings"), diagnostics: std::vec::Vec::new(), + angular_version: None, } } @@ -654,6 +674,16 @@ impl<'a> HostBindingCompilationJob<'a> { CompilationJobKind::Host } + /// Check if standalone `ɵɵinterpolate*` instructions are supported (Angular 20+). + pub fn supports_value_interpolation(&self) -> bool { + self.angular_version.map_or(true, |v| v.supports_value_interpolation()) + } + + /// Check if `ɵɵdomProperty` is supported (Angular 20+). + pub fn supports_dom_property(&self) -> bool { + self.angular_version.map_or(true, |v| v.supports_dom_property()) + } + /// Allocates a new cross-reference ID. pub fn allocate_xref_id(&mut self) -> XrefId { let id = XrefId::new(self.next_xref_id); diff --git a/crates/oxc_angular_compiler/src/pipeline/ingest.rs b/crates/oxc_angular_compiler/src/pipeline/ingest.rs index 4f75267b4..2c956eeae 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -3941,6 +3941,16 @@ pub fn ingest_host_binding<'a>( allocator: &'a Allocator, input: HostBindingInput<'a>, pool_starting_index: u32, +) -> HostBindingCompilationJob<'a> { + ingest_host_binding_with_version(allocator, input, pool_starting_index, None) +} + +/// Ingest host bindings into a `HostBindingCompilationJob` with a specific Angular version. +pub fn ingest_host_binding_with_version<'a>( + allocator: &'a Allocator, + input: HostBindingInput<'a>, + pool_starting_index: u32, + angular_version: Option, ) -> HostBindingCompilationJob<'a> { let mut job = HostBindingCompilationJob::with_pool_starting_index( allocator, @@ -3948,6 +3958,7 @@ pub fn ingest_host_binding<'a>( input.component_selector, pool_starting_index, ); + job.angular_version = angular_version; // Ingest host properties for property in input.properties { diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs b/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs index 8b917921f..e269a32a5 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/chaining.rs @@ -15,8 +15,6 @@ //! //! Ported from Angular's `template/pipeline/src/phases/chaining.ts`. -use std::sync::LazyLock; - use oxc_allocator::Box; use oxc_diagnostics::OxcDiagnostic; @@ -27,69 +25,271 @@ use crate::r3::Identifiers; /// Maximum number of chained instructions to prevent stack overflow from deep AST. const MAX_CHAIN_LENGTH: usize = 256; -/// Maps an instruction to the instruction that can follow it in a chain. -/// This allows different instructions to chain together (e.g., conditionalCreate → conditionalBranchCreate). -static CHAIN_COMPATIBILITY: LazyLock> = - LazyLock::new(|| { - let mut map = rustc_hash::FxHashMap::default(); - - // Property and binding instructions - chain with themselves - map.insert(Identifiers::PROPERTY, Identifiers::PROPERTY); - map.insert(Identifiers::ATTRIBUTE, Identifiers::ATTRIBUTE); - map.insert(Identifiers::STYLE_PROP, Identifiers::STYLE_PROP); - map.insert(Identifiers::CLASS_PROP, Identifiers::CLASS_PROP); - map.insert(Identifiers::DOM_PROPERTY, Identifiers::DOM_PROPERTY); - map.insert(Identifiers::TWO_WAY_PROPERTY, Identifiers::TWO_WAY_PROPERTY); - map.insert(Identifiers::ARIA_PROPERTY, Identifiers::ARIA_PROPERTY); +/// Returns the instruction that can follow `instruction` in a chain, or `None` if +/// the instruction is not chainable. +/// +/// Most instructions chain with themselves; the notable exception is +/// `conditionalCreate` which chains into `conditionalBranchCreate`. +const fn chain_compatible_instruction(instruction: &str) -> Option<&'static str> { + match instruction.as_bytes() { + // Property and binding instructions – chain with themselves + b if const_eq(b, Identifiers::PROPERTY.as_bytes()) => Some(Identifiers::PROPERTY), + b if const_eq(b, Identifiers::ATTRIBUTE.as_bytes()) => Some(Identifiers::ATTRIBUTE), + b if const_eq(b, Identifiers::STYLE_PROP.as_bytes()) => Some(Identifiers::STYLE_PROP), + b if const_eq(b, Identifiers::CLASS_PROP.as_bytes()) => Some(Identifiers::CLASS_PROP), + b if const_eq(b, Identifiers::DOM_PROPERTY.as_bytes()) => Some(Identifiers::DOM_PROPERTY), + b if const_eq(b, Identifiers::HOST_PROPERTY.as_bytes()) => Some(Identifiers::HOST_PROPERTY), + b if const_eq(b, Identifiers::TWO_WAY_PROPERTY.as_bytes()) => { + Some(Identifiers::TWO_WAY_PROPERTY) + } + b if const_eq(b, Identifiers::ARIA_PROPERTY.as_bytes()) => Some(Identifiers::ARIA_PROPERTY), + + // Angular 19 combined interpolation instructions – chain with themselves + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_1.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_1) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_2.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_2) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_3.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_3) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_4.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_4) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_5.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_5) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_6.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_6) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_7.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_7) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_8.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_8) + } + b if const_eq(b, Identifiers::PROPERTY_INTERPOLATE_V.as_bytes()) => { + Some(Identifiers::PROPERTY_INTERPOLATE_V) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_1.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_1) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_2.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_2) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_3.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_3) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_4.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_4) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_5.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_5) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_6.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_6) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_7.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_7) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_8.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_8) + } + b if const_eq(b, Identifiers::ATTRIBUTE_INTERPOLATE_V.as_bytes()) => { + Some(Identifiers::ATTRIBUTE_INTERPOLATE_V) + } + + // Angular 19 combined style prop interpolation instructions – chain with themselves + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_1.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_1) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_2.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_2) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_3.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_3) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_4.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_4) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_5.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_5) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_6.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_6) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_7.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_7) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_8.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_8) + } + b if const_eq(b, Identifiers::STYLE_PROP_INTERPOLATE_V.as_bytes()) => { + Some(Identifiers::STYLE_PROP_INTERPOLATE_V) + } + + // Angular 19 combined style map interpolation instructions – chain with themselves + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_1.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_1) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_2.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_2) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_3.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_3) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_4.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_4) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_5.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_5) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_6.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_6) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_7.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_7) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_8.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_8) + } + b if const_eq(b, Identifiers::STYLE_MAP_INTERPOLATE_V.as_bytes()) => { + Some(Identifiers::STYLE_MAP_INTERPOLATE_V) + } + + // Angular 19 combined class map interpolation instructions – chain with themselves + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_1.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_1) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_2.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_2) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_3.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_3) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_4.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_4) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_5.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_5) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_6.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_6) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_7.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_7) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_8.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_8) + } + b if const_eq(b, Identifiers::CLASS_MAP_INTERPOLATE_V.as_bytes()) => { + Some(Identifiers::CLASS_MAP_INTERPOLATE_V) + } // Element instructions - map.insert(Identifiers::ELEMENT, Identifiers::ELEMENT); - map.insert(Identifiers::ELEMENT_START, Identifiers::ELEMENT_START); - map.insert(Identifiers::ELEMENT_END, Identifiers::ELEMENT_END); - map.insert(Identifiers::ELEMENT_CONTAINER, Identifiers::ELEMENT_CONTAINER); - map.insert(Identifiers::ELEMENT_CONTAINER_START, Identifiers::ELEMENT_CONTAINER_START); - map.insert(Identifiers::ELEMENT_CONTAINER_END, Identifiers::ELEMENT_CONTAINER_END); + b if const_eq(b, Identifiers::ELEMENT.as_bytes()) => Some(Identifiers::ELEMENT), + b if const_eq(b, Identifiers::ELEMENT_START.as_bytes()) => Some(Identifiers::ELEMENT_START), + b if const_eq(b, Identifiers::ELEMENT_END.as_bytes()) => Some(Identifiers::ELEMENT_END), + b if const_eq(b, Identifiers::ELEMENT_CONTAINER.as_bytes()) => { + Some(Identifiers::ELEMENT_CONTAINER) + } + b if const_eq(b, Identifiers::ELEMENT_CONTAINER_START.as_bytes()) => { + Some(Identifiers::ELEMENT_CONTAINER_START) + } + b if const_eq(b, Identifiers::ELEMENT_CONTAINER_END.as_bytes()) => { + Some(Identifiers::ELEMENT_CONTAINER_END) + } // Listener instructions - map.insert(Identifiers::LISTENER, Identifiers::LISTENER); - map.insert(Identifiers::SYNTHETIC_HOST_LISTENER, Identifiers::SYNTHETIC_HOST_LISTENER); - map.insert(Identifiers::SYNTHETIC_HOST_PROPERTY, Identifiers::SYNTHETIC_HOST_PROPERTY); - map.insert(Identifiers::TWO_WAY_LISTENER, Identifiers::TWO_WAY_LISTENER); + b if const_eq(b, Identifiers::LISTENER.as_bytes()) => Some(Identifiers::LISTENER), + b if const_eq(b, Identifiers::SYNTHETIC_HOST_LISTENER.as_bytes()) => { + Some(Identifiers::SYNTHETIC_HOST_LISTENER) + } + b if const_eq(b, Identifiers::SYNTHETIC_HOST_PROPERTY.as_bytes()) => { + Some(Identifiers::SYNTHETIC_HOST_PROPERTY) + } + b if const_eq(b, Identifiers::TWO_WAY_LISTENER.as_bytes()) => { + Some(Identifiers::TWO_WAY_LISTENER) + } // Template instructions - map.insert(Identifiers::TEMPLATE_CREATE, Identifiers::TEMPLATE_CREATE); + b if const_eq(b, Identifiers::TEMPLATE_CREATE.as_bytes()) => { + Some(Identifiers::TEMPLATE_CREATE) + } // i18n instructions - map.insert(Identifiers::I18N_EXP, Identifiers::I18N_EXP); + b if const_eq(b, Identifiers::I18N_EXP.as_bytes()) => Some(Identifiers::I18N_EXP), // DOM mode instructions - map.insert(Identifiers::DOM_ELEMENT, Identifiers::DOM_ELEMENT); - map.insert(Identifiers::DOM_ELEMENT_START, Identifiers::DOM_ELEMENT_START); - map.insert(Identifiers::DOM_ELEMENT_END, Identifiers::DOM_ELEMENT_END); - map.insert(Identifiers::DOM_ELEMENT_CONTAINER, Identifiers::DOM_ELEMENT_CONTAINER); - map.insert( - Identifiers::DOM_ELEMENT_CONTAINER_START, - Identifiers::DOM_ELEMENT_CONTAINER_START, - ); - map.insert(Identifiers::DOM_ELEMENT_CONTAINER_END, Identifiers::DOM_ELEMENT_CONTAINER_END); - map.insert(Identifiers::DOM_LISTENER, Identifiers::DOM_LISTENER); - map.insert(Identifiers::DOM_TEMPLATE, Identifiers::DOM_TEMPLATE); + b if const_eq(b, Identifiers::DOM_ELEMENT.as_bytes()) => Some(Identifiers::DOM_ELEMENT), + b if const_eq(b, Identifiers::DOM_ELEMENT_START.as_bytes()) => { + Some(Identifiers::DOM_ELEMENT_START) + } + b if const_eq(b, Identifiers::DOM_ELEMENT_END.as_bytes()) => { + Some(Identifiers::DOM_ELEMENT_END) + } + b if const_eq(b, Identifiers::DOM_ELEMENT_CONTAINER.as_bytes()) => { + Some(Identifiers::DOM_ELEMENT_CONTAINER) + } + b if const_eq(b, Identifiers::DOM_ELEMENT_CONTAINER_START.as_bytes()) => { + Some(Identifiers::DOM_ELEMENT_CONTAINER_START) + } + b if const_eq(b, Identifiers::DOM_ELEMENT_CONTAINER_END.as_bytes()) => { + Some(Identifiers::DOM_ELEMENT_CONTAINER_END) + } + b if const_eq(b, Identifiers::DOM_LISTENER.as_bytes()) => Some(Identifiers::DOM_LISTENER), + b if const_eq(b, Identifiers::DOM_TEMPLATE.as_bytes()) => Some(Identifiers::DOM_TEMPLATE), // Animation instructions - map.insert(Identifiers::ANIMATION_ENTER, Identifiers::ANIMATION_ENTER); - map.insert(Identifiers::ANIMATION_LEAVE, Identifiers::ANIMATION_LEAVE); - map.insert(Identifiers::ANIMATION_ENTER_LISTENER, Identifiers::ANIMATION_ENTER_LISTENER); - map.insert(Identifiers::ANIMATION_LEAVE_LISTENER, Identifiers::ANIMATION_LEAVE_LISTENER); + b if const_eq(b, Identifiers::ANIMATION_ENTER.as_bytes()) => { + Some(Identifiers::ANIMATION_ENTER) + } + b if const_eq(b, Identifiers::ANIMATION_LEAVE.as_bytes()) => { + Some(Identifiers::ANIMATION_LEAVE) + } + b if const_eq(b, Identifiers::ANIMATION_ENTER_LISTENER.as_bytes()) => { + Some(Identifiers::ANIMATION_ENTER_LISTENER) + } + b if const_eq(b, Identifiers::ANIMATION_LEAVE_LISTENER.as_bytes()) => { + Some(Identifiers::ANIMATION_LEAVE_LISTENER) + } - // Conditional instructions - chain conditionalCreate with conditionalBranchCreate - map.insert(Identifiers::CONDITIONAL_CREATE, Identifiers::CONDITIONAL_BRANCH_CREATE); - map.insert(Identifiers::CONDITIONAL_BRANCH_CREATE, Identifiers::CONDITIONAL_BRANCH_CREATE); + // Conditional instructions – conditionalCreate chains into conditionalBranchCreate + b if const_eq(b, Identifiers::CONDITIONAL_CREATE.as_bytes()) => { + Some(Identifiers::CONDITIONAL_BRANCH_CREATE) + } + b if const_eq(b, Identifiers::CONDITIONAL_BRANCH_CREATE.as_bytes()) => { + Some(Identifiers::CONDITIONAL_BRANCH_CREATE) + } // Let declaration - map.insert(Identifiers::DECLARE_LET, Identifiers::DECLARE_LET); + b if const_eq(b, Identifiers::DECLARE_LET.as_bytes()) => Some(Identifiers::DECLARE_LET), + + _ => None, + } +} - map - }); +/// Const-compatible byte slice equality (needed because `==` on slices is not const). +const fn const_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut i = 0; + while i < a.len() { + if a[i] != b[i] { + return false; + } + i += 1; + } + true +} /// Chains compatible instructions together. /// @@ -97,13 +297,12 @@ static CHAIN_COMPATIBILITY: LazyLock) { let allocator = job.allocator; - let compatibility = &*CHAIN_COMPATIBILITY; let mut diagnostics = Vec::new(); // Chain instructions in all views for view in job.all_views_mut() { - chain_statements(allocator, &mut view.create_statements, compatibility, &mut diagnostics); - chain_statements(allocator, &mut view.update_statements, compatibility, &mut diagnostics); + chain_statements(allocator, &mut view.create_statements, &mut diagnostics); + chain_statements(allocator, &mut view.update_statements, &mut diagnostics); } job.diagnostics.extend(diagnostics); @@ -113,7 +312,6 @@ pub fn chain(job: &mut ComponentCompilationJob<'_>) { fn chain_statements<'a>( allocator: &'a oxc_allocator::Allocator, statements: &mut oxc_allocator::Vec<'a, OutputStatement<'a>>, - compatibility: &rustc_hash::FxHashMap<&'static str, &'static str>, diagnostics: &mut Vec, ) { if statements.len() < 2 { @@ -125,7 +323,7 @@ fn chain_statements<'a>( Vec::new(); for stmt in statements.iter() { if let Some(instruction) = get_instruction_name(stmt) { - if compatibility.contains_key(instruction) { + if chain_compatible_instruction(instruction).is_some() { if let Some(args) = extract_args(stmt) { let cloned_args = clone_args(allocator, args, diagnostics); stmt_info.push(Some((instruction.to_string(), cloned_args))); @@ -147,7 +345,8 @@ fn chain_statements<'a>( // Check if this instruction can chain with the previous one let can_chain = if let Some(ref current_instr) = current_instruction { // Check if the current chain's instruction can be followed by this instruction - compatibility.get(current_instr.as_str()).is_some_and(|&next| next == instruction) + chain_compatible_instruction(current_instr.as_str()) + .is_some_and(|next| next == instruction) && current_chain_indices.len() < MAX_CHAIN_LENGTH } else { false @@ -749,11 +948,10 @@ fn chain_into_statement<'a>( /// Host version - only processes the root unit (no embedded views). pub fn chain_for_host(job: &mut HostBindingCompilationJob<'_>) { let allocator = job.allocator; - let compatibility = &*CHAIN_COMPATIBILITY; let mut diagnostics = Vec::new(); - chain_statements(allocator, &mut job.root.create_statements, compatibility, &mut diagnostics); - chain_statements(allocator, &mut job.root.update_statements, compatibility, &mut diagnostics); + chain_statements(allocator, &mut job.root.create_statements, &mut diagnostics); + chain_statements(allocator, &mut job.root.update_statements, &mut diagnostics); job.diagnostics.extend(diagnostics); } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs index f3f6c1dbb..7cb143a9e 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -112,6 +112,11 @@ struct ReifyContext<'a> { mode: TemplateCompilationMode, /// Whether to use `ɵɵconditionalCreate` (Angular 20+) or `ɵɵtemplate` (Angular 19-). supports_conditional_create: bool, + /// Whether to use standalone `ɵɵinterpolate*` (Angular 20+) or combined + /// `ɵɵpropertyInterpolate*`/`ɵɵattributeInterpolate*` (Angular 19-). + supports_value_interpolation: bool, + /// Whether to use `ɵɵdomProperty` (Angular 20+) or `ɵɵhostProperty` (Angular 19-). + supports_dom_property: bool, } /// Reifies IR expressions to Output AST. @@ -141,8 +146,17 @@ pub fn reify(job: &mut ComponentCompilationJob<'_>) { } } let supports_conditional_create = job.supports_conditional_create(); - let ctx = - ReifyContext { view_fn_names, view_decls, view_vars, mode, supports_conditional_create }; + let supports_value_interpolation = job.supports_value_interpolation(); + let supports_dom_property = job.supports_dom_property(); + let ctx = ReifyContext { + view_fn_names, + view_decls, + view_vars, + mode, + supports_conditional_create, + supports_value_interpolation, + supports_dom_property, + }; // Collect xrefs of embedded views (excluding root) before splitting borrows let embedded_xrefs: std::vec::Vec = @@ -210,7 +224,16 @@ fn reify_view_to_stmts<'a>( // Reify update operations for op in view.update.iter() { - let stmt = reify_update_op(allocator, op, expressions, root_xref, ctx.mode, diagnostics); + let stmt = reify_update_op( + allocator, + op, + expressions, + root_xref, + ctx.mode, + diagnostics, + ctx.supports_value_interpolation, + ctx.supports_dom_property, + ); if let Some(s) = stmt { update_stmts.push(s); } @@ -353,6 +376,8 @@ fn reify_create_op<'a>( root_xref, ctx.mode, diagnostics, + ctx.supports_value_interpolation, + ctx.supports_dom_property, ) { handler_stmts.push(stmt); } @@ -632,6 +657,8 @@ fn reify_create_op<'a>( root_xref, ctx.mode, diagnostics, + ctx.supports_value_interpolation, + ctx.supports_dom_property, ) { handler_stmts.push(stmt); } @@ -654,6 +681,8 @@ fn reify_create_op<'a>( root_xref, ctx.mode, diagnostics, + ctx.supports_value_interpolation, + ctx.supports_dom_property, ) { handler_stmts.push(stmt); } @@ -684,6 +713,8 @@ fn reify_create_op<'a>( root_xref, ctx.mode, diagnostics, + ctx.supports_value_interpolation, + ctx.supports_dom_property, ) { handler_stmts.push(stmt); } @@ -780,38 +811,77 @@ fn reify_update_op<'a>( root_xref: XrefId, mode: TemplateCompilationMode, diagnostics: &mut Vec, + supports_value_interpolation: bool, + supports_dom_property: bool, ) -> Option> { let is_dom_only = mode == TemplateCompilationMode::DomOnly; match op { UpdateOp::Property(prop) => { - // Angular uses property() with nested interpolate*() calls for interpolated properties. - // The interpolation is handled by convert_ir_expression which generates - // ɵɵinterpolate*() calls when the expression is an Interpolation. - // Example: [title]="Hello {{name}}" -> ɵɵproperty("title", ɵɵinterpolate1("Hello ", name, "")) - let expr = convert_ir_expression(allocator, &prop.expression, expressions, root_xref); - // In DomOnly mode, use domProperty unless it's an animation binding - // Matches Angular's reify.ts line 613-621 + // Check if the expression is an interpolation AND we're targeting Angular 19 + // (which uses combined ɵɵpropertyInterpolate* instructions). + let is_interpolation = matches!(*prop.expression, IrExpression::Interpolation(_)); let is_animation = matches!(prop.binding_kind, BindingKind::LegacyAnimation | BindingKind::Animation); - if is_dom_only && !is_animation { - Some(create_dom_property_stmt(allocator, &prop.name, expr, prop.sanitizer.as_ref())) - } else if is_aria_attribute(prop.name.as_str()) { - // Use ɵɵariaProperty for ARIA attributes (e.g., aria-label, aria-hidden) - Some(create_aria_property_stmt(allocator, &prop.name, expr)) - } else { - Some(create_property_stmt_with_expr( + + if is_interpolation && !supports_value_interpolation { + // Angular 19: Use ɵɵpropertyInterpolate*("name", s0, v0, s1, ..., [sanitizer]) + let has_extra_args = prop.sanitizer.is_some(); + let (interp_args, expr_count) = reify_interpolation( + allocator, + &prop.expression, + expressions, + root_xref, + has_extra_args, + ); + Some(create_property_interpolate_stmt( allocator, &prop.name, - expr, + interp_args, + expr_count, prop.sanitizer.as_ref(), )) + } else { + // Angular 20+: Use ɵɵproperty("name", ɵɵinterpolate1(...)) or ɵɵproperty("name", expr) + let expr = + convert_ir_expression(allocator, &prop.expression, expressions, root_xref); + if is_dom_only && !is_animation { + if supports_dom_property { + Some(create_dom_property_stmt( + allocator, + &prop.name, + expr, + prop.sanitizer.as_ref(), + )) + } else { + Some(create_host_property_stmt( + allocator, + &prop.name, + expr, + prop.sanitizer.as_ref(), + )) + } + } else if is_aria_attribute(prop.name.as_str()) { + Some(create_aria_property_stmt(allocator, &prop.name, expr)) + } else { + Some(create_property_stmt_with_expr( + allocator, + &prop.name, + expr, + prop.sanitizer.as_ref(), + )) + } } } UpdateOp::InterpolateText(interp) => { // Handle multiple interpolations like "{{a}} and {{b}}" - let (args, expr_count) = - reify_interpolation(allocator, &interp.interpolation, expressions, root_xref); + let (args, expr_count) = reify_interpolation( + allocator, + &interp.interpolation, + expressions, + root_xref, + false, + ); Some(create_text_interpolate_stmt_with_args(allocator, args, expr_count)) } UpdateOp::Binding(binding) => { @@ -820,10 +890,33 @@ fn reify_update_op<'a>( Some(create_binding_stmt_with_expr(allocator, &binding.name, expr)) } UpdateOp::StyleProp(style) => { - let expr = convert_ir_expression(allocator, &style.expression, expressions, root_xref); // Strip "style." prefix if present let name = strip_prefix(&style.name, "style."); - Some(create_style_prop_stmt_with_expr(allocator, &name, expr, style.unit.as_ref())) + let is_interpolation = matches!(*style.expression, IrExpression::Interpolation(_)); + + if is_interpolation && !supports_value_interpolation { + // Angular 19: Use ɵɵstylePropInterpolate*("name", s0, v0, s1, ..., [unit]) + let has_extra_args = style.unit.is_some(); + let (interp_args, expr_count) = reify_interpolation( + allocator, + &style.expression, + expressions, + root_xref, + has_extra_args, + ); + Some(create_style_prop_interpolate_stmt( + allocator, + &name, + interp_args, + expr_count, + style.unit.as_ref(), + )) + } else { + // Angular 20+: Use ɵɵstyleProp("name", ɵɵinterpolate1(...), [unit]) + let expr = + convert_ir_expression(allocator, &style.expression, expressions, root_xref); + Some(create_style_prop_stmt_with_expr(allocator, &name, expr, style.unit.as_ref())) + } } UpdateOp::ClassProp(class) => { let expr = convert_ir_expression(allocator, &class.expression, expressions, root_xref); @@ -832,16 +925,40 @@ fn reify_update_op<'a>( Some(create_class_prop_stmt_with_expr(allocator, &name, expr)) } UpdateOp::Attribute(attr) => { - let expr = convert_ir_expression(allocator, &attr.expression, expressions, root_xref); // Strip "attr." prefix if present let name = strip_prefix(&attr.name, "attr."); - Some(create_attribute_stmt_with_expr( - allocator, - &name, - expr, - attr.sanitizer.as_ref(), - attr.namespace.as_ref(), - )) + let is_interpolation = matches!(*attr.expression, IrExpression::Interpolation(_)); + + if is_interpolation && !supports_value_interpolation { + // Angular 19: Use ɵɵattributeInterpolate*("name", s0, v0, s1, ..., [sanitizer], [ns]) + let has_extra_args = attr.sanitizer.is_some() || attr.namespace.is_some(); + let (interp_args, expr_count) = reify_interpolation( + allocator, + &attr.expression, + expressions, + root_xref, + has_extra_args, + ); + Some(create_attribute_interpolate_stmt( + allocator, + &name, + interp_args, + expr_count, + attr.sanitizer.as_ref(), + attr.namespace.as_ref(), + )) + } else { + // Angular 20+: Use ɵɵattribute("name", ɵɵinterpolate1(...)) + let expr = + convert_ir_expression(allocator, &attr.expression, expressions, root_xref); + Some(create_attribute_stmt_with_expr( + allocator, + &name, + expr, + attr.sanitizer.as_ref(), + attr.namespace.as_ref(), + )) + } } UpdateOp::Advance(adv) => Some(create_advance_stmt(allocator, adv.delta)), UpdateOp::StoreLet(store) => { @@ -880,12 +997,44 @@ fn reify_update_op<'a>( Some(create_conditional_update_stmt(allocator, expr, context_value)) } UpdateOp::StyleMap(style) => { - let expr = convert_ir_expression(allocator, &style.expression, expressions, root_xref); - Some(create_style_map_stmt(allocator, expr)) + let is_interpolation = matches!(*style.expression, IrExpression::Interpolation(_)); + + if is_interpolation && !supports_value_interpolation { + // Angular 19: Use ɵɵstyleMapInterpolate*(s0, v0, s1, ...) + let (interp_args, expr_count) = reify_interpolation( + allocator, + &style.expression, + expressions, + root_xref, + false, + ); + Some(create_style_map_interpolate_stmt(allocator, interp_args, expr_count)) + } else { + // Angular 20+: Use ɵɵstyleMap(ɵɵinterpolate1(...)) + let expr = + convert_ir_expression(allocator, &style.expression, expressions, root_xref); + Some(create_style_map_stmt(allocator, expr)) + } } UpdateOp::ClassMap(class) => { - let expr = convert_ir_expression(allocator, &class.expression, expressions, root_xref); - Some(create_class_map_stmt(allocator, expr)) + let is_interpolation = matches!(*class.expression, IrExpression::Interpolation(_)); + + if is_interpolation && !supports_value_interpolation { + // Angular 19: Use ɵɵclassMapInterpolate*(s0, v0, s1, ...) + let (interp_args, expr_count) = reify_interpolation( + allocator, + &class.expression, + expressions, + root_xref, + false, + ); + Some(create_class_map_interpolate_stmt(allocator, interp_args, expr_count)) + } else { + // Angular 20+: Use ɵɵclassMap(ɵɵinterpolate1(...)) + let expr = + convert_ir_expression(allocator, &class.expression, expressions, root_xref); + Some(create_class_map_stmt(allocator, expr)) + } } UpdateOp::DomProperty(prop) => { let expr = convert_ir_expression(allocator, &prop.expression, expressions, root_xref); @@ -895,8 +1044,16 @@ fn reify_update_op<'a>( matches!(prop.binding_kind, BindingKind::LegacyAnimation | BindingKind::Animation); if is_animation { Some(create_animation_stmt(allocator, &prop.name, expr)) - } else { + } else if supports_dom_property { Some(create_dom_property_stmt(allocator, &prop.name, expr, prop.sanitizer.as_ref())) + } else { + // Angular 19: Use ɵɵhostProperty instead of ɵɵdomProperty + Some(create_host_property_stmt( + allocator, + &prop.name, + expr, + prop.sanitizer.as_ref(), + )) } } UpdateOp::I18nExpression(i18n) => { @@ -948,11 +1105,17 @@ fn reify_update_op<'a>( } /// Reify an interpolation expression to arguments for textInterpolate. +/// Converts an IR interpolation expression to a flat list of interleaved string/expression args. +/// +/// When `has_extra_args` is true, trailing empty strings are preserved so that extra args +/// (sanitizer, namespace, unit) occupy the correct positional slot. When false, trailing +/// empty strings are dropped (the Angular runtime defaults them to ""). fn reify_interpolation<'a>( allocator: &'a oxc_allocator::Allocator, interpolation: &IrExpression<'a>, expressions: &ExpressionStore<'a>, root_xref: XrefId, + has_extra_args: bool, ) -> (OxcVec<'a, OutputExpression<'a>>, usize) { match interpolation { IrExpression::Interpolation(ir_interp) => { @@ -983,9 +1146,11 @@ fn reify_interpolation<'a>( } if ir_interp.strings.len() > ir_interp.expressions.len() { if let Some(trailing) = ir_interp.strings.last() { - // Only add trailing string if it's not empty - // (Angular drops trailing empty strings and the runtime handles it) - if !trailing.is_empty() { + // Drop trailing empty strings only when no extra args follow. + // When extra args (sanitizer, namespace, unit) are appended by the + // caller, the trailing empty string must be kept as a positional + // separator so the extra args occupy the correct slots. + if !trailing.is_empty() || has_extra_args { args.push(OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(trailing.clone()), @@ -1029,9 +1194,7 @@ fn reify_interpolation<'a>( } if ang_interp.strings.len() > ang_interp.expressions.len() { if let Some(trailing) = ang_interp.strings.last() { - // Only add trailing string if it's not empty - // (Angular drops trailing empty strings and the runtime handles it) - if !trailing.is_empty() { + if !trailing.is_empty() || has_extra_args { args.push(OutputExpression::Literal(Box::new_in( LiteralExpr { value: LiteralValue::String(trailing.clone()), @@ -1068,12 +1231,21 @@ fn reify_interpolation<'a>( pub fn reify_host(job: &mut HostBindingCompilationJob<'_>) { let allocator = job.allocator; let root_xref = job.root.xref; + let supports_value_interpolation = job.supports_value_interpolation(); + let supports_dom_property = job.supports_dom_property(); let mut diagnostics = Vec::new(); // Reify create operations (listeners) for op in job.root.create.iter() { - let stmt = - reify_host_create_op(allocator, op, &job.expressions, root_xref, &mut diagnostics); + let stmt = reify_host_create_op( + allocator, + op, + &job.expressions, + root_xref, + &mut diagnostics, + supports_value_interpolation, + supports_dom_property, + ); if let Some(s) = stmt { job.root.create_statements.push(s); } @@ -1089,6 +1261,8 @@ pub fn reify_host(job: &mut HostBindingCompilationJob<'_>) { root_xref, TemplateCompilationMode::Full, &mut diagnostics, + supports_value_interpolation, + supports_dom_property, ); if let Some(s) = stmt { job.root.update_statements.push(s); @@ -1105,6 +1279,8 @@ fn reify_host_create_op<'a>( expressions: &ExpressionStore<'a>, root_xref: XrefId, diagnostics: &mut Vec, + supports_value_interpolation: bool, + supports_dom_property: bool, ) -> Option> { match op { CreateOp::Listener(listener) => { @@ -1129,6 +1305,8 @@ fn reify_host_create_op<'a>( root_xref, TemplateCompilationMode::Full, diagnostics, + supports_value_interpolation, + supports_dom_property, ) { handler_stmts.push(stmt); } @@ -1178,6 +1356,8 @@ fn reify_host_create_op<'a>( root_xref, TemplateCompilationMode::Full, diagnostics, + supports_value_interpolation, + supports_dom_property, ) { handler_stmts.push(stmt); } @@ -1341,6 +1521,8 @@ fn reify_track_by<'a>( // Reify each op in track_by_ops into output statements let mut statements = OxcVec::new_in(allocator); for track_op in track_ops.iter() { + // Track-by functions don't contain property/attribute interpolation, + // so version flags don't matter here. if let Some(stmt) = reify_update_op( allocator, track_op, @@ -1348,6 +1530,8 @@ fn reify_track_by<'a>( root_xref, TemplateCompilationMode::Full, diagnostics, + true, // supports_value_interpolation (not relevant for track-by) + true, // supports_dom_property (not relevant for track-by) ) { statements.push(stmt); } diff --git a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs index 0955c06ba..dfbe89c41 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/bindings.rs @@ -6,7 +6,11 @@ use oxc_span::Atom; use crate::output::ast::{ LiteralExpr, LiteralValue, OutputExpression, OutputStatement, ReadPropExpr, ReadVarExpr, }; -use crate::r3::{Identifiers, get_text_interpolate_instruction}; +use crate::r3::{ + Identifiers, get_attribute_interpolate_instruction, get_class_map_interpolate_instruction, + get_property_interpolate_instruction, get_style_map_interpolate_instruction, + get_style_prop_interpolate_instruction, get_text_interpolate_instruction, +}; use super::super::utils::create_instruction_call_stmt; @@ -270,3 +274,188 @@ pub fn create_text_interpolate_stmt_with_args<'a>( }; create_instruction_call_stmt(allocator, instruction, args) } + +/// Creates an ɵɵpropertyInterpolate*() call statement (Angular 19). +/// +/// For Angular 19, property bindings with interpolation use combined instructions: +/// `ɵɵpropertyInterpolate1("title", "Hello ", name, "")` instead of +/// `ɵɵproperty("title", ɵɵinterpolate1("Hello ", name, ""))`. +/// +/// Arguments: name, [s0, v0, s1, v1, ..., sN], [sanitizer] +pub fn create_property_interpolate_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + name: &Atom<'a>, + interp_args: OxcVec<'a, OutputExpression<'a>>, + expr_count: usize, + sanitizer: Option<&Atom<'a>>, +) -> OutputStatement<'a> { + // Save length before consuming interp_args — the simple case check must use + // the interpolation args count, not the final args count (which includes name + // and sanitizer). Otherwise a singleton like `{{url}}` with a sanitizer would + // mis-select propertyInterpolate1 instead of propertyInterpolate. + let interp_args_len = interp_args.len(); + let mut args = OxcVec::new_in(allocator); + // First arg: property name + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(name.clone()), source_span: None }, + allocator, + ))); + // Then interleaved strings and expressions + for arg in interp_args { + args.push(arg); + } + // Optional sanitizer + if let Some(san) = sanitizer { + args.push(create_sanitizer_expr(allocator, san)); + } + let instruction = if expr_count == 1 && interp_args_len == 1 { + // Simple case: just name + value (no surrounding strings) + // e.g. propertyInterpolate("src", url, sanitizerFn) + Identifiers::PROPERTY_INTERPOLATE + } else { + get_property_interpolate_instruction(expr_count) + }; + create_instruction_call_stmt(allocator, instruction, args) +} + +/// Creates an ɵɵattributeInterpolate*() call statement (Angular 19). +/// +/// For Angular 19, attribute bindings with interpolation use combined instructions: +/// `ɵɵattributeInterpolate1("title", "Hello ", name, "")` instead of +/// `ɵɵattribute("title", ɵɵinterpolate1("Hello ", name, ""))`. +/// +/// Arguments: name, [s0, v0, s1, v1, ..., sN], [sanitizer], [namespace] +pub fn create_attribute_interpolate_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + name: &Atom<'a>, + interp_args: OxcVec<'a, OutputExpression<'a>>, + expr_count: usize, + sanitizer: Option<&Atom<'a>>, + namespace: Option<&Atom<'a>>, +) -> OutputStatement<'a> { + // Save length before consuming — same reason as create_property_interpolate_stmt. + let interp_args_len = interp_args.len(); + let mut args = OxcVec::new_in(allocator); + // First arg: attribute name + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(name.clone()), source_span: None }, + allocator, + ))); + // Then interleaved strings and expressions + for arg in interp_args { + args.push(arg); + } + // Optional sanitizer, or null if namespace is present + if sanitizer.is_some() || namespace.is_some() { + if let Some(san) = sanitizer { + args.push(create_sanitizer_expr(allocator, san)); + } else { + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Null, source_span: None }, + allocator, + ))); + } + } + // Optional namespace + if let Some(ns) = namespace { + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(ns.clone()), source_span: None }, + allocator, + ))); + } + let instruction = if expr_count == 1 && interp_args_len == 1 { + Identifiers::ATTRIBUTE_INTERPOLATE + } else { + get_attribute_interpolate_instruction(expr_count) + }; + create_instruction_call_stmt(allocator, instruction, args) +} + +/// Creates an ɵɵhostProperty() call statement (Angular 19). +/// +/// For Angular 19, host/DomOnly property bindings use `ɵɵhostProperty` instead of `ɵɵdomProperty`. +pub fn create_host_property_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + name: &Atom<'a>, + value: OutputExpression<'a>, + sanitizer: Option<&Atom<'a>>, +) -> OutputStatement<'a> { + let remapped_name = remap_dom_property(name); + let mut args = OxcVec::new_in(allocator); + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(remapped_name), source_span: None }, + allocator, + ))); + args.push(value); + if let Some(san) = sanitizer { + args.push(create_sanitizer_expr(allocator, san)); + } + create_instruction_call_stmt(allocator, Identifiers::HOST_PROPERTY, args) +} + +/// Creates an ɵɵstylePropInterpolate*() call statement (Angular 19). +/// +/// For Angular 19, style prop bindings with interpolation use combined instructions: +/// `ɵɵstylePropInterpolate1("width", "", expr, "px", "px")` instead of +/// `ɵɵstyleProp("width", ɵɵinterpolate1("", expr, "px"), "px")`. +/// +/// Signature: `ɵɵstylePropInterpolateN(prop, s0, v0, ..., [unit])` +pub fn create_style_prop_interpolate_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + name: &Atom<'a>, + interp_args: OxcVec<'a, OutputExpression<'a>>, + expr_count: usize, + unit: Option<&Atom<'a>>, +) -> OutputStatement<'a> { + let mut args = OxcVec::new_in(allocator); + // First arg: style property name + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(name.clone()), source_span: None }, + allocator, + ))); + // Then interleaved strings and expressions + for arg in interp_args { + args.push(arg); + } + // Optional unit suffix (valueSuffix) + if let Some(unit_val) = unit { + args.push(OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(unit_val.clone()), source_span: None }, + allocator, + ))); + } + let instruction = get_style_prop_interpolate_instruction(expr_count); + create_instruction_call_stmt(allocator, instruction, args) +} + +/// Creates an ɵɵstyleMapInterpolate*() call statement (Angular 19). +/// +/// For Angular 19, style map bindings with interpolation use combined instructions: +/// `ɵɵstyleMapInterpolate1("", expr, "")` instead of +/// `ɵɵstyleMap(ɵɵinterpolate1("", expr, ""))`. +/// +/// Signature: `ɵɵstyleMapInterpolateN(s0, v0, ...)` +pub fn create_style_map_interpolate_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + interp_args: OxcVec<'a, OutputExpression<'a>>, + expr_count: usize, +) -> OutputStatement<'a> { + let instruction = get_style_map_interpolate_instruction(expr_count); + create_instruction_call_stmt(allocator, instruction, interp_args) +} + +/// Creates an ɵɵclassMapInterpolate*() call statement (Angular 19). +/// +/// For Angular 19, class map bindings with interpolation use combined instructions: +/// `ɵɵclassMapInterpolate1("", expr, "")` instead of +/// `ɵɵclassMap(ɵɵinterpolate1("", expr, ""))`. +/// +/// Signature: `ɵɵclassMapInterpolateN(s0, v0, ...)` +pub fn create_class_map_interpolate_stmt<'a>( + allocator: &'a oxc_allocator::Allocator, + interp_args: OxcVec<'a, OutputExpression<'a>>, + expr_count: usize, +) -> OutputStatement<'a> { + let instruction = get_class_map_interpolate_instruction(expr_count); + create_instruction_call_stmt(allocator, instruction, interp_args) +} diff --git a/crates/oxc_angular_compiler/src/r3/identifiers.rs b/crates/oxc_angular_compiler/src/r3/identifiers.rs index c51a95a1b..47d167788 100644 --- a/crates/oxc_angular_compiler/src/r3/identifiers.rs +++ b/crates/oxc_angular_compiler/src/r3/identifiers.rs @@ -160,6 +160,174 @@ impl Identifiers { /// Interpolate a value with 9+ expressions (variadic). pub const INTERPOLATE_V: &'static str = "ɵɵinterpolateV"; + // ======================================================================== + // Property Interpolation Instructions (Angular 19 — combined instructions) + // ======================================================================== + + /// Property interpolation (0 expressions, simple stringify). + pub const PROPERTY_INTERPOLATE: &'static str = "ɵɵpropertyInterpolate"; + + /// Property interpolation with 1 expression. + pub const PROPERTY_INTERPOLATE_1: &'static str = "ɵɵpropertyInterpolate1"; + + /// Property interpolation with 2 expressions. + pub const PROPERTY_INTERPOLATE_2: &'static str = "ɵɵpropertyInterpolate2"; + + /// Property interpolation with 3 expressions. + pub const PROPERTY_INTERPOLATE_3: &'static str = "ɵɵpropertyInterpolate3"; + + /// Property interpolation with 4 expressions. + pub const PROPERTY_INTERPOLATE_4: &'static str = "ɵɵpropertyInterpolate4"; + + /// Property interpolation with 5 expressions. + pub const PROPERTY_INTERPOLATE_5: &'static str = "ɵɵpropertyInterpolate5"; + + /// Property interpolation with 6 expressions. + pub const PROPERTY_INTERPOLATE_6: &'static str = "ɵɵpropertyInterpolate6"; + + /// Property interpolation with 7 expressions. + pub const PROPERTY_INTERPOLATE_7: &'static str = "ɵɵpropertyInterpolate7"; + + /// Property interpolation with 8 expressions. + pub const PROPERTY_INTERPOLATE_8: &'static str = "ɵɵpropertyInterpolate8"; + + /// Property interpolation with 9+ expressions (variadic). + pub const PROPERTY_INTERPOLATE_V: &'static str = "ɵɵpropertyInterpolateV"; + + // ======================================================================== + // Attribute Interpolation Instructions (Angular 19 — combined instructions) + // ======================================================================== + + /// Attribute interpolation (0 expressions, simple stringify). + pub const ATTRIBUTE_INTERPOLATE: &'static str = "ɵɵattributeInterpolate"; + + /// Attribute interpolation with 1 expression. + pub const ATTRIBUTE_INTERPOLATE_1: &'static str = "ɵɵattributeInterpolate1"; + + /// Attribute interpolation with 2 expressions. + pub const ATTRIBUTE_INTERPOLATE_2: &'static str = "ɵɵattributeInterpolate2"; + + /// Attribute interpolation with 3 expressions. + pub const ATTRIBUTE_INTERPOLATE_3: &'static str = "ɵɵattributeInterpolate3"; + + /// Attribute interpolation with 4 expressions. + pub const ATTRIBUTE_INTERPOLATE_4: &'static str = "ɵɵattributeInterpolate4"; + + /// Attribute interpolation with 5 expressions. + pub const ATTRIBUTE_INTERPOLATE_5: &'static str = "ɵɵattributeInterpolate5"; + + /// Attribute interpolation with 6 expressions. + pub const ATTRIBUTE_INTERPOLATE_6: &'static str = "ɵɵattributeInterpolate6"; + + /// Attribute interpolation with 7 expressions. + pub const ATTRIBUTE_INTERPOLATE_7: &'static str = "ɵɵattributeInterpolate7"; + + /// Attribute interpolation with 8 expressions. + pub const ATTRIBUTE_INTERPOLATE_8: &'static str = "ɵɵattributeInterpolate8"; + + /// Attribute interpolation with 9+ expressions (variadic). + pub const ATTRIBUTE_INTERPOLATE_V: &'static str = "ɵɵattributeInterpolateV"; + + // ======================================================================== + // Style Prop Interpolation Instructions (Angular 19 — combined instructions) + // ======================================================================== + + /// Style prop interpolation with 1 expression. + pub const STYLE_PROP_INTERPOLATE_1: &'static str = "ɵɵstylePropInterpolate1"; + + /// Style prop interpolation with 2 expressions. + pub const STYLE_PROP_INTERPOLATE_2: &'static str = "ɵɵstylePropInterpolate2"; + + /// Style prop interpolation with 3 expressions. + pub const STYLE_PROP_INTERPOLATE_3: &'static str = "ɵɵstylePropInterpolate3"; + + /// Style prop interpolation with 4 expressions. + pub const STYLE_PROP_INTERPOLATE_4: &'static str = "ɵɵstylePropInterpolate4"; + + /// Style prop interpolation with 5 expressions. + pub const STYLE_PROP_INTERPOLATE_5: &'static str = "ɵɵstylePropInterpolate5"; + + /// Style prop interpolation with 6 expressions. + pub const STYLE_PROP_INTERPOLATE_6: &'static str = "ɵɵstylePropInterpolate6"; + + /// Style prop interpolation with 7 expressions. + pub const STYLE_PROP_INTERPOLATE_7: &'static str = "ɵɵstylePropInterpolate7"; + + /// Style prop interpolation with 8 expressions. + pub const STYLE_PROP_INTERPOLATE_8: &'static str = "ɵɵstylePropInterpolate8"; + + /// Style prop interpolation with 9+ expressions (variadic). + pub const STYLE_PROP_INTERPOLATE_V: &'static str = "ɵɵstylePropInterpolateV"; + + // ======================================================================== + // Style Map Interpolation Instructions (Angular 19 — combined instructions) + // ======================================================================== + + /// Style map interpolation with 1 expression. + pub const STYLE_MAP_INTERPOLATE_1: &'static str = "ɵɵstyleMapInterpolate1"; + + /// Style map interpolation with 2 expressions. + pub const STYLE_MAP_INTERPOLATE_2: &'static str = "ɵɵstyleMapInterpolate2"; + + /// Style map interpolation with 3 expressions. + pub const STYLE_MAP_INTERPOLATE_3: &'static str = "ɵɵstyleMapInterpolate3"; + + /// Style map interpolation with 4 expressions. + pub const STYLE_MAP_INTERPOLATE_4: &'static str = "ɵɵstyleMapInterpolate4"; + + /// Style map interpolation with 5 expressions. + pub const STYLE_MAP_INTERPOLATE_5: &'static str = "ɵɵstyleMapInterpolate5"; + + /// Style map interpolation with 6 expressions. + pub const STYLE_MAP_INTERPOLATE_6: &'static str = "ɵɵstyleMapInterpolate6"; + + /// Style map interpolation with 7 expressions. + pub const STYLE_MAP_INTERPOLATE_7: &'static str = "ɵɵstyleMapInterpolate7"; + + /// Style map interpolation with 8 expressions. + pub const STYLE_MAP_INTERPOLATE_8: &'static str = "ɵɵstyleMapInterpolate8"; + + /// Style map interpolation with 9+ expressions (variadic). + pub const STYLE_MAP_INTERPOLATE_V: &'static str = "ɵɵstyleMapInterpolateV"; + + // ======================================================================== + // Class Map Interpolation Instructions (Angular 19 — combined instructions) + // ======================================================================== + + /// Class map interpolation with 1 expression. + pub const CLASS_MAP_INTERPOLATE_1: &'static str = "ɵɵclassMapInterpolate1"; + + /// Class map interpolation with 2 expressions. + pub const CLASS_MAP_INTERPOLATE_2: &'static str = "ɵɵclassMapInterpolate2"; + + /// Class map interpolation with 3 expressions. + pub const CLASS_MAP_INTERPOLATE_3: &'static str = "ɵɵclassMapInterpolate3"; + + /// Class map interpolation with 4 expressions. + pub const CLASS_MAP_INTERPOLATE_4: &'static str = "ɵɵclassMapInterpolate4"; + + /// Class map interpolation with 5 expressions. + pub const CLASS_MAP_INTERPOLATE_5: &'static str = "ɵɵclassMapInterpolate5"; + + /// Class map interpolation with 6 expressions. + pub const CLASS_MAP_INTERPOLATE_6: &'static str = "ɵɵclassMapInterpolate6"; + + /// Class map interpolation with 7 expressions. + pub const CLASS_MAP_INTERPOLATE_7: &'static str = "ɵɵclassMapInterpolate7"; + + /// Class map interpolation with 8 expressions. + pub const CLASS_MAP_INTERPOLATE_8: &'static str = "ɵɵclassMapInterpolate8"; + + /// Class map interpolation with 9+ expressions (variadic). + pub const CLASS_MAP_INTERPOLATE_V: &'static str = "ɵɵclassMapInterpolateV"; + + // ======================================================================== + // Host Property Instruction (Angular 19 — replaces domProperty) + // ======================================================================== + + /// Host property binding (Angular 19). Angular 20+ uses `ɵɵdomProperty`. + pub const HOST_PROPERTY: &'static str = "ɵɵhostProperty"; + // ======================================================================== // Text Instructions // ======================================================================== @@ -856,3 +1024,80 @@ pub fn get_pipe_bind_instruction(arg_count: usize) -> &'static str { _ => Identifiers::PIPE_BIND_V, } } + +/// Returns the property interpolation instruction name for the given expression count (Angular 19). +pub fn get_property_interpolate_instruction(expr_count: usize) -> &'static str { + match expr_count { + 0 => Identifiers::PROPERTY_INTERPOLATE, + 1 => Identifiers::PROPERTY_INTERPOLATE_1, + 2 => Identifiers::PROPERTY_INTERPOLATE_2, + 3 => Identifiers::PROPERTY_INTERPOLATE_3, + 4 => Identifiers::PROPERTY_INTERPOLATE_4, + 5 => Identifiers::PROPERTY_INTERPOLATE_5, + 6 => Identifiers::PROPERTY_INTERPOLATE_6, + 7 => Identifiers::PROPERTY_INTERPOLATE_7, + 8 => Identifiers::PROPERTY_INTERPOLATE_8, + _ => Identifiers::PROPERTY_INTERPOLATE_V, + } +} + +/// Returns the attribute interpolation instruction name for the given expression count (Angular 19). +pub fn get_attribute_interpolate_instruction(expr_count: usize) -> &'static str { + match expr_count { + 0 => Identifiers::ATTRIBUTE_INTERPOLATE, + 1 => Identifiers::ATTRIBUTE_INTERPOLATE_1, + 2 => Identifiers::ATTRIBUTE_INTERPOLATE_2, + 3 => Identifiers::ATTRIBUTE_INTERPOLATE_3, + 4 => Identifiers::ATTRIBUTE_INTERPOLATE_4, + 5 => Identifiers::ATTRIBUTE_INTERPOLATE_5, + 6 => Identifiers::ATTRIBUTE_INTERPOLATE_6, + 7 => Identifiers::ATTRIBUTE_INTERPOLATE_7, + 8 => Identifiers::ATTRIBUTE_INTERPOLATE_8, + _ => Identifiers::ATTRIBUTE_INTERPOLATE_V, + } +} + +/// Returns the style prop interpolation instruction name for the given expression count (Angular 19). +pub fn get_style_prop_interpolate_instruction(expr_count: usize) -> &'static str { + match expr_count { + 1 => Identifiers::STYLE_PROP_INTERPOLATE_1, + 2 => Identifiers::STYLE_PROP_INTERPOLATE_2, + 3 => Identifiers::STYLE_PROP_INTERPOLATE_3, + 4 => Identifiers::STYLE_PROP_INTERPOLATE_4, + 5 => Identifiers::STYLE_PROP_INTERPOLATE_5, + 6 => Identifiers::STYLE_PROP_INTERPOLATE_6, + 7 => Identifiers::STYLE_PROP_INTERPOLATE_7, + 8 => Identifiers::STYLE_PROP_INTERPOLATE_8, + _ => Identifiers::STYLE_PROP_INTERPOLATE_V, + } +} + +/// Returns the style map interpolation instruction name for the given expression count (Angular 19). +pub fn get_style_map_interpolate_instruction(expr_count: usize) -> &'static str { + match expr_count { + 1 => Identifiers::STYLE_MAP_INTERPOLATE_1, + 2 => Identifiers::STYLE_MAP_INTERPOLATE_2, + 3 => Identifiers::STYLE_MAP_INTERPOLATE_3, + 4 => Identifiers::STYLE_MAP_INTERPOLATE_4, + 5 => Identifiers::STYLE_MAP_INTERPOLATE_5, + 6 => Identifiers::STYLE_MAP_INTERPOLATE_6, + 7 => Identifiers::STYLE_MAP_INTERPOLATE_7, + 8 => Identifiers::STYLE_MAP_INTERPOLATE_8, + _ => Identifiers::STYLE_MAP_INTERPOLATE_V, + } +} + +/// Returns the class map interpolation instruction name for the given expression count (Angular 19). +pub fn get_class_map_interpolate_instruction(expr_count: usize) -> &'static str { + match expr_count { + 1 => Identifiers::CLASS_MAP_INTERPOLATE_1, + 2 => Identifiers::CLASS_MAP_INTERPOLATE_2, + 3 => Identifiers::CLASS_MAP_INTERPOLATE_3, + 4 => Identifiers::CLASS_MAP_INTERPOLATE_4, + 5 => Identifiers::CLASS_MAP_INTERPOLATE_5, + 6 => Identifiers::CLASS_MAP_INTERPOLATE_6, + 7 => Identifiers::CLASS_MAP_INTERPOLATE_7, + 8 => Identifiers::CLASS_MAP_INTERPOLATE_8, + _ => Identifiers::CLASS_MAP_INTERPOLATE_V, + } +} diff --git a/crates/oxc_angular_compiler/src/r3/mod.rs b/crates/oxc_angular_compiler/src/r3/mod.rs index eb26c3ce1..f0567961c 100644 --- a/crates/oxc_angular_compiler/src/r3/mod.rs +++ b/crates/oxc_angular_compiler/src/r3/mod.rs @@ -6,6 +6,8 @@ pub mod identifiers; pub use identifiers::{ - Identifiers, get_interpolate_instruction, get_pipe_bind_instruction, - get_pure_function_instruction, get_text_interpolate_instruction, + Identifiers, get_attribute_interpolate_instruction, get_class_map_interpolate_instruction, + get_interpolate_instruction, get_pipe_bind_instruction, get_property_interpolate_instruction, + get_pure_function_instruction, get_style_map_interpolate_instruction, + get_style_prop_interpolate_instruction, get_text_interpolate_instruction, }; diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index d53690070..9fcaebe71 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -7527,3 +7527,309 @@ fn test_if_block_angular_v20_explicit() { "Angular 20 should emit ɵɵconditionalCreate. Got:\n{js}" ); } + +// ============================================================================ +// Angular 19 Property/Attribute Interpolation Version Gating (Issue #107) +// ============================================================================ +// These tests verify that when targeting Angular 19, the compiler emits +// ɵɵpropertyInterpolate*/ɵɵattributeInterpolate* instead of +// ɵɵproperty + nested ɵɵinterpolate*, and ɵɵhostProperty instead of ɵɵdomProperty. + +#[test] +fn test_property_interpolation_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#"
static
"#, + "TestComponent", + Some(v19), + ); + // Non-interpolation property bindings should still use ɵɵproperty + assert!( + js.contains("ɵɵproperty("), + "Angular 19 should use ɵɵproperty for non-interpolation bindings. Got:\n{js}" + ); + // Should NOT emit ɵɵinterpolate for non-interpolation bindings + assert!( + !js.contains("ɵɵinterpolate"), + "Angular 19 should NOT emit ɵɵinterpolate for non-interpolation bindings. Got:\n{js}" + ); +} + +#[test] +fn test_property_interpolation_angular_v19_with_interpolation() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#"
static
"#, + "TestComponent", + Some(v19), + ); + // Angular 19 should use combined ɵɵpropertyInterpolate1 instruction + assert!( + js.contains("ɵɵpropertyInterpolate1("), + "Angular 19 should emit ɵɵpropertyInterpolate1 for property interpolation. Got:\n{js}" + ); + // Should NOT emit standalone ɵɵinterpolate + assert!( + !js.contains("ɵɵinterpolate1("), + "Angular 19 should NOT emit standalone ɵɵinterpolate1. Got:\n{js}" + ); + insta::assert_snapshot!("property_interpolation_angular_v19", js); +} + +#[test] +fn test_property_interpolation_angular_v19_multiple() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#"
static
"#, + "TestComponent", + Some(v19), + ); + // Angular 19 should use ɵɵpropertyInterpolate2 for 2 expressions + assert!( + js.contains("ɵɵpropertyInterpolate2("), + "Angular 19 should emit ɵɵpropertyInterpolate2 for 2-expression interpolation. Got:\n{js}" + ); + insta::assert_snapshot!("property_interpolation_angular_v19_multiple", js); +} + +#[test] +fn test_property_interpolation_angular_v19_simple() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#"
static
"#, + "TestComponent", + Some(v19), + ); + // Single expression with empty strings → ɵɵpropertyInterpolate + assert!( + js.contains("ɵɵpropertyInterpolate("), + "Angular 19 should emit ɵɵpropertyInterpolate for simple interpolation. Got:\n{js}" + ); + insta::assert_snapshot!("property_interpolation_angular_v19_simple", js); +} + +#[test] +fn test_attribute_interpolation_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#""#, + "TestComponent", + Some(v19), + ); + // Angular 19 should use combined ɵɵattributeInterpolate2 instruction + assert!( + js.contains("ɵɵattributeInterpolate2("), + "Angular 19 should emit ɵɵattributeInterpolate2 for attribute interpolation. Got:\n{js}" + ); + // Should NOT emit standalone ɵɵinterpolate + assert!( + !js.contains("ɵɵinterpolate2("), + "Angular 19 should NOT emit standalone ɵɵinterpolate2. Got:\n{js}" + ); + insta::assert_snapshot!("attribute_interpolation_angular_v19", js); +} + +#[test] +fn test_property_interpolation_angular_v20_default() { + // Default (no version set) should use ɵɵproperty + ɵɵinterpolate1 (Angular 20+ behavior) + let js = compile_template_to_js_with_version( + r#"
static
"#, + "TestComponent", + None, + ); + assert!( + js.contains("ɵɵinterpolate1("), + "Default (latest) should emit ɵɵinterpolate1. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵpropertyInterpolate"), + "Default (latest) should NOT emit ɵɵpropertyInterpolate. Got:\n{js}" + ); +} + +#[test] +fn test_property_interpolation_angular_v20_explicit() { + let v20 = AngularVersion::new(20, 0, 0); + let js = compile_template_to_js_with_version( + r#"
static
"#, + "TestComponent", + Some(v20), + ); + assert!(js.contains("ɵɵinterpolate1("), "Angular 20 should emit ɵɵinterpolate1. Got:\n{js}"); + assert!( + !js.contains("ɵɵpropertyInterpolate"), + "Angular 20 should NOT emit ɵɵpropertyInterpolate. Got:\n{js}" + ); +} + +// ======================================================================== +// Angular 19 version-gating: stylePropInterpolate, styleMapInterpolate, classMapInterpolate +// ======================================================================== + +#[test] +fn test_style_prop_interpolation_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + // Use interpolation syntax (not binding syntax) for style prop + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v19), + ); + // Angular 19 should use combined ɵɵstylePropInterpolate1 instruction + assert!( + js.contains("ɵɵstylePropInterpolate1("), + "Angular 19 should emit ɵɵstylePropInterpolate1 for style prop interpolation. Got:\n{js}" + ); + // Should NOT use standalone interpolate nested in styleProp + assert!( + !js.contains("ɵɵinterpolate1("), + "Angular 19 should NOT emit standalone ɵɵinterpolate1 for style prop. Got:\n{js}" + ); + insta::assert_snapshot!("style_prop_interpolation_angular_v19", js); +} + +#[test] +fn test_style_prop_interpolation_angular_v19_with_unit() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v19), + ); + // Angular 19 should use combined ɵɵstylePropInterpolate1 with unit + assert!( + js.contains("ɵɵstylePropInterpolate1("), + "Angular 19 should emit ɵɵstylePropInterpolate1 for style prop with unit. Got:\n{js}" + ); + insta::assert_snapshot!("style_prop_interpolation_angular_v19_with_unit", js); +} + +#[test] +fn test_style_prop_interpolation_angular_v20() { + let v20 = AngularVersion::new(20, 0, 0); + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v20), + ); + // Angular 20+ should use styleProp + standalone interpolate + assert!( + !js.contains("ɵɵstylePropInterpolate"), + "Angular 20 should NOT emit ɵɵstylePropInterpolate. Got:\n{js}" + ); +} + +#[test] +fn test_style_map_interpolation_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v19), + ); + // Angular 19 should use combined ɵɵstyleMapInterpolate1 instruction + assert!( + js.contains("ɵɵstyleMapInterpolate1("), + "Angular 19 should emit ɵɵstyleMapInterpolate1 for style map interpolation. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵinterpolate1("), + "Angular 19 should NOT emit standalone ɵɵinterpolate1 for style map. Got:\n{js}" + ); + insta::assert_snapshot!("style_map_interpolation_angular_v19", js); +} + +#[test] +fn test_style_map_interpolation_angular_v20() { + let v20 = AngularVersion::new(20, 0, 0); + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v20), + ); + // Angular 20+ should use styleMap + standalone interpolate + assert!( + !js.contains("ɵɵstyleMapInterpolate"), + "Angular 20 should NOT emit ɵɵstyleMapInterpolate. Got:\n{js}" + ); +} + +#[test] +fn test_class_map_interpolation_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v19), + ); + // Angular 19 should use combined ɵɵclassMapInterpolate1 instruction + assert!( + js.contains("ɵɵclassMapInterpolate1("), + "Angular 19 should emit ɵɵclassMapInterpolate1 for class map interpolation. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵinterpolate1("), + "Angular 19 should NOT emit standalone ɵɵinterpolate1 for class map. Got:\n{js}" + ); + insta::assert_snapshot!("class_map_interpolation_angular_v19", js); +} + +#[test] +fn test_class_map_interpolation_angular_v20() { + let v20 = AngularVersion::new(20, 0, 0); + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v20), + ); + // Angular 20+ should use classMap + standalone interpolate + assert!( + !js.contains("ɵɵclassMapInterpolate"), + "Angular 20 should NOT emit ɵɵclassMapInterpolate. Got:\n{js}" + ); +} + +#[test] +fn test_style_prop_singleton_collapsed_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + // Singleton interpolation style.color="{{expr}}" is collapsed to plain styleProp + // for both v19 and v20+. Angular v19's collateInterpolationArgs maps singleton + // empty-string interpolations to the plain instruction (index 0). + let js = compile_template_to_js_with_version( + r#"
"#, + "TestComponent", + Some(v19), + ); + assert!( + js.contains("ɵɵstyleProp("), + "Angular 19 singleton style interpolation should use plain ɵɵstyleProp. Got:\n{js}" + ); + // Should NOT use standalone interpolate or combined interpolate + assert!( + !js.contains("ɵɵinterpolate1(") && !js.contains("ɵɵstylePropInterpolate"), + "Angular 19 singleton should NOT emit interpolate instructions. Got:\n{js}" + ); +} + +#[test] +fn test_property_singleton_interpolation_with_sanitizer_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + // Singleton property interpolation `src="{{url}}"` with sanitizer (img src → URL sanitizer). + // Must select ɵɵpropertyInterpolate (non-numbered), NOT ɵɵpropertyInterpolate1. + // propertyInterpolate(propName, value, sanitizer?) — takes value directly. + // propertyInterpolate1(propName, prefix, v0, suffix, sanitizer?) — expects prefix/suffix. + let js = + compile_template_to_js_with_version(r#""#, "TestComponent", Some(v19)); + // Must use ɵɵpropertyInterpolate (simple variant), not ɵɵpropertyInterpolate1 + assert!( + js.contains("ɵɵpropertyInterpolate("), + "Singleton interpolation with sanitizer should use ɵɵpropertyInterpolate, not ɵɵpropertyInterpolate1. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵpropertyInterpolate1("), + "Should NOT emit ɵɵpropertyInterpolate1 for singleton. Got:\n{js}" + ); + // Must include sanitizer function + assert!(js.contains("ɵɵsanitizeUrl"), "Should include ɵɵsanitizeUrl sanitizer. Got:\n{js}"); + insta::assert_snapshot!("property_singleton_interpolation_with_sanitizer_v19", js); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__attribute_interpolation_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__attribute_interpolation_angular_v19.snap new file mode 100644 index 000000000..72071ff82 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__attribute_interpolation_angular_v19.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.ɵɵnamespaceSVG(); + i0.ɵɵelement(0,"svg"); + } + if ((rf & 2)) { i0.ɵɵattributeInterpolate2("viewBox","0 0 ",ctx.svgSize," ",ctx.svgSize); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__class_map_interpolation_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__class_map_interpolation_angular_v19.snap new file mode 100644 index 000000000..09e29be8e --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__class_map_interpolation_angular_v19.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵelement(0,"div"); } + if ((rf & 2)) { i0.ɵɵclassMapInterpolate1("prefix",ctx.expr,"suffix"); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19.snap new file mode 100644 index 000000000..ef540fde0 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19.snap @@ -0,0 +1,12 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵelementStart(0,"div",0); + i0.ɵɵtext(1,"static"); + i0.ɵɵelementEnd(); + } + if ((rf & 2)) { i0.ɵɵpropertyInterpolate1("title","Hello ",ctx.name); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_multiple.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_multiple.snap new file mode 100644 index 000000000..6102be880 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_multiple.snap @@ -0,0 +1,12 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵelementStart(0,"div",0); + i0.ɵɵtext(1,"static"); + i0.ɵɵelementEnd(); + } + if ((rf & 2)) { i0.ɵɵpropertyInterpolate2("title","",ctx.first," and ",ctx.second); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_simple.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_simple.snap new file mode 100644 index 000000000..d1fb8663a --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_interpolation_angular_v19_simple.snap @@ -0,0 +1,12 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵelementStart(0,"div",0); + i0.ɵɵtext(1,"static"); + i0.ɵɵelementEnd(); + } + if ((rf & 2)) { i0.ɵɵpropertyInterpolate("title",ctx.name); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_singleton_interpolation_with_sanitizer_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_singleton_interpolation_with_sanitizer_v19.snap new file mode 100644 index 000000000..94b30558a --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__property_singleton_interpolation_with_sanitizer_v19.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵelement(0,"img",0); } + if ((rf & 2)) { i0.ɵɵpropertyInterpolate("src",ctx.url,i0.ɵɵsanitizeUrl); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_map_interpolation_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_map_interpolation_angular_v19.snap new file mode 100644 index 000000000..aeeb354c3 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_map_interpolation_angular_v19.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵelement(0,"div"); } + if ((rf & 2)) { i0.ɵɵstyleMapInterpolate1("width:",ctx.expr,"px"); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19.snap new file mode 100644 index 000000000..014f630d1 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵelement(0,"div"); } + if ((rf & 2)) { i0.ɵɵstylePropInterpolate1("width","prefix",ctx.expr,"suffix"); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19_with_unit.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19_with_unit.snap new file mode 100644 index 000000000..d84349039 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__style_prop_interpolation_angular_v19_with_unit.snap @@ -0,0 +1,8 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵelement(0,"div"); } + if ((rf & 2)) { i0.ɵɵstylePropInterpolate1("width","prefix",ctx.size,"suffix","px"); } +}