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
24 changes: 24 additions & 0 deletions crates/oxc_angular_compiler/src/component/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ impl AngularVersion {
self.major >= 22
}

/// Check if this version emits control instructions at all (v21.2.0+).
///
/// Angular's `specializeControlProperties` phase and the
/// `ɵɵcontrolCreate()`/`ɵɵcontrol()` instructions were introduced in
/// v21.2.0 for the `formField` control directive. Earlier versions never
/// emit them.
pub fn supports_control_instructions(&self) -> bool {
self.major > 21 || (self.major == 21 && self.minor >= 2)
}

/// Check if this version emits control instructions for the extended set of
/// control properties (v22.0.0+).
///
Expand All @@ -97,6 +107,20 @@ impl AngularVersion {
self.major >= 22
}

/// Check if this version anchors `ɵɵcontrolCreate()` at the element's first
/// create instruction (v22.2.0+).
///
/// Through v22.1.x, `findCreateInstruction` kept the *last* matching create
/// op, so `ɵɵcontrolCreate()` landed after `ElementEnd`/`ContainerEnd` (and
/// `Template` was a valid anchor). v22.2.0 switched to the *first* matching
/// op and narrowed the anchor kinds to `Element`, `ElementStart`,
/// `Container`, and `ContainerStart`, so the instruction sits right after
/// the element start (before listeners) and `ng-template` no longer gets
/// control instructions.
pub fn uses_first_control_anchor(&self) -> bool {
self.major > 22 || (self.major == 22 && self.minor >= 2)
}

/// Check if this version uses modern optional-chaining semantics (v22.0.0+).
///
/// Angular v22 changed the safe-navigation operator (`?.`) in template
Expand Down
65 changes: 6 additions & 59 deletions crates/oxc_angular_compiler/src/pipeline/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@ use crate::ir::expression::{
TwoWayBindingSetExpr,
};
use crate::ir::ops::{
BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp, ControlCreateOp,
CreateOp, CreateOpBase, DeclareLetOp, DeferOnOp, DeferOp, DeferWhenOp, ElementEndOp,
ElementStartOp, ExtractedAttributeOp, I18nAttributesOp, I18nEndOp, I18nPlaceholder,
I18nSlotHandle, I18nStartOp, IcuEndOp, IcuStartOp, InterpolateTextOp, ListenerOp, LocalRef,
ProjectionOp, RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StatementOp, StoreLetOp,
TemplateOp, TextOp, TwoWayListenerOp, UpdateOp, UpdateOpBase, XrefId,
BindingOp, ConditionalBranchCreateOp, ConditionalOp, ConditionalUpdateOp, CreateOp,
CreateOpBase, DeclareLetOp, DeferOnOp, DeferOp, DeferWhenOp, ElementEndOp, ElementStartOp,
ExtractedAttributeOp, I18nAttributesOp, I18nEndOp, I18nPlaceholder, I18nSlotHandle,
I18nStartOp, IcuEndOp, IcuStartOp, InterpolateTextOp, ListenerOp, LocalRef, ProjectionOp,
RepeaterCreateOp, RepeaterOp, RepeaterVarNames, SlotId, StatementOp, StoreLetOp, TemplateOp,
TextOp, TwoWayListenerOp, UpdateOp, UpdateOpBase, XrefId,
};
use crate::output::ast::OutputExpression;
use crate::pipeline::compilation::{AliasVariable, ContextVariable};
Expand Down Expand Up @@ -1113,47 +1113,6 @@ fn ingest_element<'a>(
// Process local references
let local_refs = ingest_references_owned(allocator, element.references);

// Check for a control property binding to create a ControlCreateOp, matching
// Angular v22's `specializeControlProperties`. The eligible properties and the
// binding kinds they accept are:
// - formField / formControl: `[..]` property binding
// - formControlName: property binding or static attribute
// - ngModel: property, two-way `[(ngModel)]`, or static attribute
// (v21 only emitted this for `formField`; v22 broadened it, notably to
// two-way `[(ngModel)]`, which now also emits `ɵɵcontrolCreate()`.)
use crate::ast::expression::BindingType;
// `formField` is the v21 baseline; the extended set (formControl/
// formControlName/ngModel) was added in v22. Default to latest when unknown.
let extended_controls =
job.angular_version.map_or(true, |v| v.supports_extended_control_properties());
let field_input_span = element
.inputs
.iter()
.find_map(|input| {
let eligible = match input.name.as_str() {
"formField" => input.binding_type == BindingType::Property,
"formControl" if extended_controls => input.binding_type == BindingType::Property,
"formControlName" if extended_controls => {
input.binding_type == BindingType::Property
}
"ngModel" if extended_controls => {
matches!(input.binding_type, BindingType::Property | BindingType::TwoWay)
}
_ => false,
};
eligible.then_some(input.source_span)
})
.or_else(|| {
// Static attributes (`formControlName="name"`, `ngModel`) -> Attribute op (v22+).
if !extended_controls {
return None;
}
element.attributes.iter().find_map(|attr| {
matches!(attr.name.as_str(), "formControlName" | "ngModel")
.then_some(attr.source_span)
})
});

// Always create ElementStart/ElementEnd pairs, even for void/self-closing elements.
// The empty_elements phase will collapse them to Element when appropriate.
// This matches TypeScript Angular's ingest.ts which always creates start/end pairs.
Expand Down Expand Up @@ -1311,18 +1270,6 @@ fn ingest_element<'a>(
if let Some(view) = job.view_mut(view_xref) {
view.create.push(end_op);
}

// We want to ensure that the controlCreateOp is after the ops that create the element.
// Ported from Angular's ingest.ts lines 319-327.
// If the element has a [field] property binding, add ControlCreateOp.
// This is used for form control bindings that require synchronization.
if let Some(span) = field_input_span {
if let Some(view) = job.view_mut(view_xref) {
view.create.push(CreateOp::ControlCreate(ControlCreateOp {
base: CreateOpBase { source_span: Some(span), ..Default::default() },
}));
}
}
}

/// Ingests static attributes from R3TextAttribute, preserving i18n metadata.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ use crate::ast::expression::{AbsoluteSourceSpan, AngularExpression, EmptyExpr, P
use crate::ir::enums::{AnimationBindingKind, BindingKind};
use crate::ir::expression::IrExpression;
use crate::ir::ops::{
AnimationBindingOp, AttributeOp, ControlOp, CreateOp, DomPropertyOp, PropertyOp,
TwoWayPropertyOp, UpdateOp, UpdateOpBase, XrefId,
AnimationBindingOp, AttributeOp, CreateOp, DomPropertyOp, PropertyOp, TwoWayPropertyOp,
UpdateOp, UpdateOpBase, XrefId,
};
use crate::pipeline::compilation::{
ComponentCompilationJob, HostBindingCompilationJob, TemplateCompilationMode,
Expand Down Expand Up @@ -88,10 +88,6 @@ fn split_ns_name(name: &str) -> (Option<&str>, &str) {
pub fn specialize_bindings(job: &mut ComponentCompilationJob<'_>) {
let allocator = job.allocator;
let mode = job.mode;
// v22 broadened control-property specialization beyond `formField`. Default to
// the latest behaviour when the target version is unknown.
let extended_controls =
job.angular_version.map_or(true, |v| v.supports_extended_control_properties());

// First pass: Build element map from create operations
let mut elements: FxHashMap<XrefId, ElementInfo> = FxHashMap::default();
Expand All @@ -116,14 +112,12 @@ pub fn specialize_bindings(job: &mut ComponentCompilationJob<'_>) {
let mut all_non_bindable: Vec<XrefId> = Vec::new();

// Process root view
let root_non_bindable =
specialize_in_view(&mut job.root.update, &allocator, &elements, mode, extended_controls);
let root_non_bindable = specialize_in_view(&mut job.root.update, &allocator, &elements, mode);
all_non_bindable.extend(root_non_bindable);

// Process embedded views
for view in job.views.values_mut() {
let view_non_bindable =
specialize_in_view(&mut view.update, &allocator, &elements, mode, extended_controls);
let view_non_bindable = specialize_in_view(&mut view.update, &allocator, &elements, mode);
all_non_bindable.extend(view_non_bindable);
}

Expand Down Expand Up @@ -174,7 +168,7 @@ fn get_element_info(op: &CreateOp<'_>) -> Option<(XrefId, ElementInfo)> {
}

/// Creates a placeholder expression.
fn create_placeholder_expression<'a>(
pub(super) fn create_placeholder_expression<'a>(
allocator: &'a oxc_allocator::Allocator,
) -> Box<'a, IrExpression<'a>> {
let empty_expr = AngularExpression::Empty(Box::new_in(
Expand All @@ -191,7 +185,6 @@ fn specialize_in_view<'a>(
allocator: &'a oxc_allocator::Allocator,
_elements: &FxHashMap<XrefId, ElementInfo>,
mode: TemplateCompilationMode,
extended_controls: bool,
) -> Vec<XrefId> {
// Track ops to remove (ngNonBindable)
let mut to_remove: Vec<std::ptr::NonNull<UpdateOp<'a>>> = Vec::new();
Expand Down Expand Up @@ -307,46 +300,6 @@ fn specialize_in_view<'a>(
});
cursor.replace_current(new_op);
}
} else if name.as_str() == "formField"
|| (extended_controls
&& matches!(
name.as_str(),
"formControl" | "formControlName" | "ngModel"
))
{
// A control property (`[formField]`, `[formControl]`,
// `[formControlName]`, `[ngModel]`) still binds as a regular property,
// but Angular also emits a separate control instruction
// (`ɵɵcontrol()`) after the property update. `formField` is the v21
// baseline; the rest were added in v22 (`extended_controls`).
if let Some(UpdateOp::Binding(binding)) = cursor.current_mut() {
let expression = std::mem::replace(
&mut binding.expression,
create_placeholder_expression(allocator),
);
let property_op = UpdateOp::Property(PropertyOp {
base: UpdateOpBase { source_span, ..Default::default() },
target,
name: binding.name.clone(),
expression,
is_host: false, // Template mode
security_context,
sanitizer: None,
is_structural: false,
i18n_context: None,
i18n_message: binding.i18n_message,
binding_kind,
});
let control_op = UpdateOp::Control(ControlOp {
base: UpdateOpBase { source_span, ..Default::default() },
target,
name: binding.name.clone(),
expression: create_placeholder_expression(allocator),
security_context,
});
cursor.replace_current(property_op);
cursor.insert_after(control_op);
}
} else {
// Regular property binding
// Note: In host binding mode, this would become DomPropertyOp
Expand Down Expand Up @@ -390,20 +343,6 @@ fn specialize_in_view<'a>(
sanitizer: None,
});
cursor.replace_current(new_op);
// Angular v22: a two-way `[(ngModel)]` is a control property, so it
// also emits a control update instruction (`ɵɵcontrol()`) after the
// two-way property update (paired with the `ɵɵcontrolCreate()` added
// during ingest). Gated to v22+ via `extended_controls`.
if extended_controls && name.as_str() == "ngModel" {
let control_op = UpdateOp::Control(ControlOp {
base: UpdateOpBase { source_span, ..Default::default() },
target,
name,
expression: create_placeholder_expression(allocator),
security_context,
});
cursor.insert_after(control_op);
}
}
}
BindingKind::I18n | BindingKind::ClassName | BindingKind::StyleProperty => {
Expand Down
8 changes: 8 additions & 0 deletions crates/oxc_angular_compiler/src/pipeline/phases/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ mod resolve_i18n_expression_placeholders;
mod resolve_names;
mod resolve_sanitizers;
mod save_restore_view;
mod specialize_control_properties;
mod store_let_optimization;
mod strip_nonrequired_parentheses;
mod style_binding_specialization;
Expand Down Expand Up @@ -174,6 +175,13 @@ pub static PHASES: &[Phase] = &[
run_host: Some(binding_specialization::specialize_bindings_for_host),
name: "specializeBindings",
},
// Phase 9b: specializeControlProperties (Template only)
Phase {
kind: CompilationJobKind::Template,
run: specialize_control_properties::specialize_control_properties,
run_host: None,
name: "specializeControlProperties",
},
// Phase 10: convertAnimations (Both)
Phase {
kind: CompilationJobKind::Both,
Expand Down
Loading
Loading