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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/oxc_angular_compiler/src/component/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions crates/oxc_angular_compiler/src/component/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
16 changes: 16 additions & 0 deletions crates/oxc_angular_compiler/src/pipeline/compilation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<crate::output::ast::OutputExpression<'a>>,
/// 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<AngularVersion>,
/// Diagnostics collected during compilation.
pub diagnostics: std::vec::Vec<OxcDiagnostic>,
}
Expand Down Expand Up @@ -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(),
}
}
Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::AngularVersion>,
}

impl Default for IngestOptions<'_> {
Expand All @@ -129,6 +136,7 @@ impl Default for IngestOptions<'_> {
template_source: None,
all_deferrable_deps_fn: None,
pool_starting_index: 0,
angular_version: None,
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down
78 changes: 55 additions & 23 deletions crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ struct ReifyContext<'a> {
view_vars: FxHashMap<XrefId, u32>,
/// 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.
Expand Down Expand Up @@ -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<XrefId> =
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
131 changes: 130 additions & 1 deletion crates/oxc_angular_compiler/tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AngularVersion>,
) -> 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)
Expand All @@ -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);
Expand Down Expand Up @@ -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) { <div>Visible</div> }",
"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) { <div>True</div> } @else { <div>False</div> }",
"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) { <div>One</div> } @case (2) { <div>Two</div> } @default { <div>Other</div> } }",
"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) { <div>Visible</div> }",
"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) { <div>Visible</div> }",
"TestComponent",
Some(v20),
);
assert!(
js.contains("ɵɵconditionalCreate("),
"Angular 20 should emit ɵɵconditionalCreate. Got:\n{js}"
);
}
Loading
Loading