From 408e8da848025ac8c8ddfbcb41d34ddbe48b3bbc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 13 Mar 2026 12:43:33 +0800 Subject: [PATCH] fix: version-gate conditionalCreate/conditionalBranchCreate for Angular 19 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Angular 19 does not have ɵɵconditionalCreate/ɵɵconditionalBranchCreate runtime instructions (introduced in Angular 20). When angularVersion < 20, emit ɵɵtemplate instead for @if/@switch blocks. Also wire angularVersion from PluginOptions through to the compiler pipeline. Closes #105 Co-Authored-By: Claude Opus 4.6 --- .../src/component/metadata.rs | 8 ++ .../src/component/transform.rs | 5 + .../src/pipeline/compilation.rs | 16 +++ .../src/pipeline/ingest.rs | 11 ++ .../src/pipeline/phases/reify/mod.rs | 78 ++++++++--- .../tests/integration_test.rs | 131 +++++++++++++++++- ...ntegration_test__if_block_angular_v19.snap | 17 +++ ...ation_test__if_else_block_angular_v19.snap | 27 ++++ ...ration_test__switch_block_angular_v19.snap | 39 ++++++ napi/angular-compiler/vite-plugin/index.ts | 19 +++ 10 files changed, 327 insertions(+), 24 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__if_block_angular_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__if_else_block_angular_v19.snap create mode 100644 crates/oxc_angular_compiler/tests/snapshots/integration_test__switch_block_angular_v19.snap diff --git a/crates/oxc_angular_compiler/src/component/metadata.rs b/crates/oxc_angular_compiler/src/component/metadata.rs index 514410169..99b179a11 100644 --- a/crates/oxc_angular_compiler/src/component/metadata.rs +++ b/crates/oxc_angular_compiler/src/component/metadata.rs @@ -37,6 +37,14 @@ impl AngularVersion { self.major >= 19 } + /// Check if this version supports `ɵɵconditionalCreate`/`ɵɵconditionalBranchCreate` (v20.0.0+). + /// + /// Angular v20 introduced `ɵɵconditionalCreate` and `ɵɵconditionalBranchCreate` + /// instructions for `@if`/`@switch` blocks. Earlier versions use `ɵɵtemplate` instead. + pub fn supports_conditional_create(&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 10290cde5..be73ee096 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2371,6 +2371,8 @@ fn compile_component_full<'a>( // Use the shared pool starting index to avoid duplicate constant names // when compiling multiple components in the same file pool_starting_index, + // Pass Angular version for feature-gated instruction selection + angular_version: options.angular_version, }; let mut job = ingest_component_with_options( @@ -2792,6 +2794,7 @@ pub fn compile_template_to_js_with_options<'a>( template_source: Some(template), all_deferrable_deps_fn: None, pool_starting_index: 0, // Standalone template compilation starts from 0 + angular_version: options.angular_version, }; // Stage 3-5: Ingest and compile @@ -2963,6 +2966,7 @@ pub fn compile_template_for_hmr<'a>( template_source: Some(template), all_deferrable_deps_fn: None, pool_starting_index: 0, // HMR template compilation starts from 0 + angular_version: options.angular_version, }; // Stage 3-5: Ingest and compile @@ -3585,6 +3589,7 @@ pub fn compile_template_for_linker<'a>( template_source: Some(template), all_deferrable_deps_fn: None, pool_starting_index: 0, + angular_version: None, }; let component_name_atom = Atom::from_in(component_name, allocator); diff --git a/crates/oxc_angular_compiler/src/pipeline/compilation.rs b/crates/oxc_angular_compiler/src/pipeline/compilation.rs index f0a1896ca..759b305df 100644 --- a/crates/oxc_angular_compiler/src/pipeline/compilation.rs +++ b/crates/oxc_angular_compiler/src/pipeline/compilation.rs @@ -12,6 +12,7 @@ use oxc_diagnostics::OxcDiagnostic; use oxc_span::{Atom, Span}; use rustc_hash::{FxBuildHasher, FxHashMap}; +use crate::AngularVersion; use crate::ir::enums::CompatibilityMode; use crate::ir::list::{CreateOpList, UpdateOpList}; use crate::ir::ops::XrefId; @@ -183,6 +184,12 @@ pub struct ComponentCompilationJob<'a> { /// Causes `ngContentSelectors` to be emitted in the component definition. /// This is populated by the `generate_projection_def` phase. pub content_selectors: Option>, + /// Angular version for feature-gated instruction selection. + /// + /// When set to a version < 20, the compiler emits `ɵɵtemplate` instead of + /// `ɵɵconditionalCreate`/`ɵɵconditionalBranchCreate` for `@if`/`@switch` blocks. + /// When `None`, assumes latest Angular version (v20+ behavior). + pub angular_version: Option, /// Diagnostics collected during compilation. pub diagnostics: std::vec::Vec, } @@ -232,6 +239,7 @@ impl<'a> ComponentCompilationJob<'a> { defer_meta: DeferMetadata::PerBlock { blocks: FxHashMap::default() }, all_deferrable_deps_fn: None, content_selectors: None, + angular_version: None, diagnostics: std::vec::Vec::new(), } } @@ -245,6 +253,14 @@ impl<'a> ComponentCompilationJob<'a> { self } + /// Check if `ɵɵconditionalCreate` 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 `ɵɵtemplate` instead. + pub fn supports_conditional_create(&self) -> bool { + self.angular_version.map_or(true, |v: AngularVersion| v.supports_conditional_create()) + } + /// 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 8ca3a7bfa..4f75267b4 100644 --- a/crates/oxc_angular_compiler/src/pipeline/ingest.rs +++ b/crates/oxc_angular_compiler/src/pipeline/ingest.rs @@ -115,6 +115,13 @@ pub struct IngestOptions<'a> { /// /// Default is 0 (start from _c0). pub pool_starting_index: u32, + + /// Angular version for feature-gated instruction selection. + /// + /// When set to a version < 20, the compiler emits `ɵɵtemplate` instead of + /// `ɵɵconditionalCreate`/`ɵɵconditionalBranchCreate` for `@if`/`@switch` blocks. + /// When `None`, assumes latest Angular version (v20+ behavior). + pub angular_version: Option, } impl Default for IngestOptions<'_> { @@ -129,6 +136,7 @@ impl Default for IngestOptions<'_> { template_source: None, all_deferrable_deps_fn: None, pool_starting_index: 0, + angular_version: None, } } } @@ -732,6 +740,9 @@ pub fn ingest_component_with_options<'a>( // This is used when DeferBlockDepsEmitMode::PerComponent to reference the shared deps function job.all_deferrable_deps_fn = options.all_deferrable_deps_fn; + // Set Angular version for feature-gated instruction selection + job.angular_version = options.angular_version; + let root_xref = job.root.xref; for node in template { 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 6373d3609..f3f6c1dbb 100644 --- a/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs +++ b/crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs @@ -110,6 +110,8 @@ struct ReifyContext<'a> { view_vars: FxHashMap, /// Template compilation mode (Full or DomOnly). mode: TemplateCompilationMode, + /// Whether to use `ɵɵconditionalCreate` (Angular 20+) or `ɵɵtemplate` (Angular 19-). + supports_conditional_create: bool, } /// Reifies IR expressions to Output AST. @@ -138,7 +140,9 @@ pub fn reify(job: &mut ComponentCompilationJob<'_>) { view_vars.insert(view.xref, vars); } } - let ctx = ReifyContext { view_fn_names, view_decls, view_vars, mode }; + let supports_conditional_create = job.supports_conditional_create(); + let ctx = + ReifyContext { view_fn_names, view_decls, view_vars, mode, supports_conditional_create }; // Collect xrefs of embedded views (excluding root) before splitting borrows let embedded_xrefs: std::vec::Vec = @@ -447,20 +451,34 @@ fn reify_create_op<'a>( Some(create_declare_let_stmt(allocator, slot)) } CreateOp::Conditional(cond) => { - // Emit ɵɵconditionalCreate instruction for the first branch in @if/@switch // Look up the function name for this branch's view let fn_name = ctx.view_fn_names.get(&cond.xref).cloned(); let slot = cond.slot.map(|s| s.0).unwrap_or(0); - Some(create_conditional_create_stmt( - allocator, - slot, - fn_name, - cond.decls, - cond.vars, - cond.tag.as_ref(), - cond.attributes, - cond.local_refs_index, - )) + if ctx.supports_conditional_create { + // Angular 20+: Emit ɵɵconditionalCreate for the first branch in @if/@switch + Some(create_conditional_create_stmt( + allocator, + slot, + fn_name, + cond.decls, + cond.vars, + cond.tag.as_ref(), + cond.attributes, + cond.local_refs_index, + )) + } else { + // Angular 19: Emit ɵɵtemplate instead (conditionalCreate doesn't exist) + Some(create_template_stmt( + allocator, + slot, + fn_name, + cond.decls, + cond.vars, + cond.tag.as_ref(), + cond.attributes, + cond.local_refs_index, + )) + } } CreateOp::RepeaterCreate(repeater) => { // Emit repeaterCreate instruction for @for @@ -708,20 +726,34 @@ fn reify_create_op<'a>( } } CreateOp::ConditionalBranch(branch) => { - // Emit ɵɵconditionalBranchCreate instruction for branches after the first in @if/@switch // Look up the function name for this branch's view let fn_name = ctx.view_fn_names.get(&branch.xref).cloned(); let slot = branch.slot.map(|s| s.0).unwrap_or(0); - Some(create_conditional_branch_create_stmt( - allocator, - slot, - fn_name, - branch.decls, - branch.vars, - branch.tag.as_ref(), - branch.attributes, - branch.local_refs_index, - )) + if ctx.supports_conditional_create { + // Angular 20+: Emit ɵɵconditionalBranchCreate for branches after the first + Some(create_conditional_branch_create_stmt( + allocator, + slot, + fn_name, + branch.decls, + branch.vars, + branch.tag.as_ref(), + branch.attributes, + branch.local_refs_index, + )) + } else { + // Angular 19: Emit ɵɵtemplate instead (conditionalBranchCreate doesn't exist) + Some(create_template_stmt( + allocator, + slot, + fn_name, + branch.decls, + branch.vars, + branch.tag.as_ref(), + branch.attributes, + branch.local_refs_index, + )) + } } CreateOp::ControlCreate(_) => { // Emit ɵɵcontrolCreate instruction for control binding initialization diff --git a/crates/oxc_angular_compiler/tests/integration_test.rs b/crates/oxc_angular_compiler/tests/integration_test.rs index 0a2edf25f..d53690070 100644 --- a/crates/oxc_angular_compiler/tests/integration_test.rs +++ b/crates/oxc_angular_compiler/tests/integration_test.rs @@ -17,6 +17,17 @@ use oxc_span::Atom; /// Compiles an Angular template to JavaScript. fn compile_template_to_js(template: &str, component_name: &str) -> String { + compile_template_to_js_with_version(template, component_name, None) +} + +/// Compiles an Angular template to JavaScript targeting a specific Angular version. +fn compile_template_to_js_with_version( + template: &str, + component_name: &str, + angular_version: Option, +) -> String { + use oxc_angular_compiler::pipeline::ingest::{IngestOptions, ingest_component_with_options}; + let allocator = Allocator::default(); // Stage 1: Parse HTML (with expansion forms enabled for ICU/plural support) @@ -40,7 +51,17 @@ fn compile_template_to_js(template: &str, component_name: &str) -> String { } // Stage 3: Ingest R3 AST into IR - let mut job = ingest_component(&allocator, Atom::from(component_name), r3_result.nodes); + let mut job = if let Some(version) = angular_version { + let options = IngestOptions { angular_version: Some(version), ..Default::default() }; + ingest_component_with_options( + &allocator, + Atom::from(component_name), + r3_result.nodes, + options, + ) + } else { + ingest_component(&allocator, Atom::from(component_name), r3_result.nodes) + }; // Stage 4-5: Transform and emit let result = compile_template(&mut job); @@ -7398,3 +7419,111 @@ export class TestComponent { decl.members ); } + +// ============================================================================ +// Angular Version Gating Tests (Issue #105) +// ============================================================================ +// These tests verify that when targeting Angular 19, the compiler emits +// ɵɵtemplate instead of ɵɵconditionalCreate/ɵɵconditionalBranchCreate +// for @if/@switch blocks, since those instructions don't exist in Angular 19. + +#[test] +fn test_if_block_angular_v19() { + let v19 = AngularVersion::new(19, 0, 0); + let js = compile_template_to_js_with_version( + r"@if (condition) {
Visible
}", + "TestComponent", + Some(v19), + ); + // Angular 19 should use ɵɵtemplate, NOT ɵɵconditionalCreate + assert!( + js.contains("ɵɵtemplate("), + "Angular 19 should emit ɵɵtemplate for @if blocks. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵconditionalCreate("), + "Angular 19 should NOT emit ɵɵconditionalCreate. Got:\n{js}" + ); + // Update instruction (ɵɵconditional) should still be emitted + assert!( + js.contains("ɵɵconditional("), + "Angular 19 should still emit ɵɵconditional for update. Got:\n{js}" + ); + insta::assert_snapshot!("if_block_angular_v19", js); +} + +#[test] +fn test_if_else_block_angular_v19() { + let v19 = AngularVersion::new(19, 2, 0); + let js = compile_template_to_js_with_version( + r"@if (condition) {
True
} @else {
False
}", + "TestComponent", + Some(v19), + ); + // Angular 19 should use ɵɵtemplate for all branches, NOT conditionalCreate/conditionalBranchCreate + assert!( + js.contains("ɵɵtemplate("), + "Angular 19 should emit ɵɵtemplate for @if/@else blocks. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵconditionalCreate("), + "Angular 19 should NOT emit ɵɵconditionalCreate. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵconditionalBranchCreate("), + "Angular 19 should NOT emit ɵɵconditionalBranchCreate. Got:\n{js}" + ); + insta::assert_snapshot!("if_else_block_angular_v19", js); +} + +#[test] +fn test_switch_block_angular_v19() { + let v19 = AngularVersion::new(19, 0, 0); + let js = compile_template_to_js_with_version( + r"@switch (value) { @case (1) {
One
} @case (2) {
Two
} @default {
Other
} }", + "TestComponent", + Some(v19), + ); + // Angular 19 should use ɵɵtemplate for all @switch cases + assert!( + js.contains("ɵɵtemplate("), + "Angular 19 should emit ɵɵtemplate for @switch blocks. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵconditionalCreate("), + "Angular 19 should NOT emit ɵɵconditionalCreate for @switch. Got:\n{js}" + ); + assert!( + !js.contains("ɵɵconditionalBranchCreate("), + "Angular 19 should NOT emit ɵɵconditionalBranchCreate for @switch. Got:\n{js}" + ); + insta::assert_snapshot!("switch_block_angular_v19", js); +} + +#[test] +fn test_if_block_angular_v20_default() { + // Default (no version set) should emit conditionalCreate (Angular 20+ behavior) + let js = compile_template_to_js_with_version( + r"@if (condition) {
Visible
}", + "TestComponent", + None, + ); + assert!( + js.contains("ɵɵconditionalCreate("), + "Default (latest) should emit ɵɵconditionalCreate. Got:\n{js}" + ); +} + +#[test] +fn test_if_block_angular_v20_explicit() { + let v20 = AngularVersion::new(20, 0, 0); + let js = compile_template_to_js_with_version( + r"@if (condition) {
Visible
}", + "TestComponent", + Some(v20), + ); + assert!( + js.contains("ɵɵconditionalCreate("), + "Angular 20 should emit ɵɵconditionalCreate. Got:\n{js}" + ); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__if_block_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__if_block_angular_v19.snap new file mode 100644 index 000000000..1be494ef6 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__if_block_angular_v19.snap @@ -0,0 +1,17 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Conditional_0_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵtext(0," "); + i0.ɵɵelementStart(1,"div"); + i0.ɵɵtext(2,"Visible"); + i0.ɵɵelementEnd(); + i0.ɵɵtext(3," "); + } +} +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵtemplate(0,TestComponent_Conditional_0_Template,4,0); } + if ((rf & 2)) { i0.ɵɵconditional((ctx.condition? 0: -1)); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__if_else_block_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__if_else_block_angular_v19.snap new file mode 100644 index 000000000..20901117e --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__if_else_block_angular_v19.snap @@ -0,0 +1,27 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Conditional_0_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵtext(0," "); + i0.ɵɵelementStart(1,"div"); + i0.ɵɵtext(2,"True"); + i0.ɵɵelementEnd(); + i0.ɵɵtext(3," "); + } +} +function TestComponent_Conditional_1_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵtext(0," "); + i0.ɵɵelementStart(1,"div"); + i0.ɵɵtext(2,"False"); + i0.ɵɵelementEnd(); + i0.ɵɵtext(3," "); + } +} +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵtemplate(0,TestComponent_Conditional_0_Template,4,0)(1,TestComponent_Conditional_1_Template, + 4,0); } + if ((rf & 2)) { i0.ɵɵconditional((ctx.condition? 0: 1)); } +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__switch_block_angular_v19.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__switch_block_angular_v19.snap new file mode 100644 index 000000000..9201d8d32 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__switch_block_angular_v19.snap @@ -0,0 +1,39 @@ +--- +source: crates/oxc_angular_compiler/tests/integration_test.rs +expression: js +--- +function TestComponent_Case_0_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵtext(0," "); + i0.ɵɵelementStart(1,"div"); + i0.ɵɵtext(2,"One"); + i0.ɵɵelementEnd(); + i0.ɵɵtext(3," "); + } +} +function TestComponent_Case_1_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵtext(0," "); + i0.ɵɵelementStart(1,"div"); + i0.ɵɵtext(2,"Two"); + i0.ɵɵelementEnd(); + i0.ɵɵtext(3," "); + } +} +function TestComponent_Case_2_Template(rf,ctx) { + if ((rf & 1)) { + i0.ɵɵtext(0," "); + i0.ɵɵelementStart(1,"div"); + i0.ɵɵtext(2,"Other"); + i0.ɵɵelementEnd(); + i0.ɵɵtext(3," "); + } +} +function TestComponent_Template(rf,ctx) { + if ((rf & 1)) { i0.ɵɵtemplate(0,TestComponent_Case_0_Template,4,0)(1,TestComponent_Case_1_Template, + 4,0)(2,TestComponent_Case_2_Template,4,0); } + if ((rf & 2)) { + let tmp_0_0; + i0.ɵɵconditional((((tmp_0_0 = ctx.value) === 1)? 0: ((tmp_0_0 === 2)? 1: 2))); + } +} diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index ef9d1c6bb..881f71161 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -28,6 +28,7 @@ import { compileForHmrSync, type TransformOptions, type ResolvedResources, + type AngularVersion, } from '#binding' import { buildOptimizerPlugin } from './angular-build-optimizer-plugin.js' @@ -65,6 +66,22 @@ export interface PluginOptions { /** Path to main.server.ts for SSR manifest generation. Auto-detected from src/main.server.ts if not specified. */ ssrEntry?: string + + /** + * Angular version to target. + * + * Controls which runtime instructions are emitted. For example, Angular 19 + * uses `ɵɵtemplate` for `@if`/`@switch` blocks, while Angular 20+ uses + * `ɵɵconditionalCreate`/`ɵɵconditionalBranchCreate`. + * + * When not set, assumes latest Angular version (v20+ behavior). + * + * @example + * ```ts + * angular({ angularVersion: { major: 19, minor: 0, patch: 0 } }) + * ``` + */ + angularVersion?: AngularVersion } // Match all TypeScript files - we'll filter by @Component/@Directive decorator in the handler @@ -100,6 +117,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { : (options.sourceMap?.scripts ?? true), zoneless: options.zoneless ?? false, fileReplacements, + angularVersion: options.angularVersion, } let resolvedConfig: ResolvedConfig @@ -453,6 +471,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { sourcemap: pluginOptions.sourceMap, jit: pluginOptions.jit, hmr: pluginOptions.liveReload && watchMode, + angularVersion: pluginOptions.angularVersion, } const result = await transformAngularFile(code, actualId, transformOptions, resources)