Skip to content

Commit 8d189cf

Browse files
committed
fix(angular): align formField control binding with Angular
Angular's control-directives pipeline treats [formField] as a normal property binding and then inserts separate controlCreate/control instructions. Our Rust pipeline had drifted from that logic and rewrote [formField] into a custom ControlOp carrying the bound value, which emitted legacy output like ɵɵcontrol(ctx.myField, "formField") without ever writing the directive input via ɵɵproperty("formField", ...). In Angular 21 signal forms this leaves FormField.field unset and can surface as NG0950 at runtime. This change restores the expected control flow for template bindings: - keep [formField] as a regular PropertyOp - emit a separate ControlOp after the property update - reify ControlOp to zero-arg ɵɵcontrol() - stop extracting duplicate const metadata from ControlOp itself The tests now cover both the regression and Angular's mixed-order control fixture behavior: - [formField] must emit ɵɵproperty("formField", ...) plus ɵɵcontrol() - legacy ɵɵcontrol(value, "formField") output is rejected - mixed [formField]/[value] bindings preserve update order - extracted const metadata preserves per-element binding order Verified with targeted cargo test runs for the new regression, control binding extraction, mixed property ordering, const ordering, pipe slot propagation, and the existing [field] non-control regression.
1 parent f73935a commit 8d189cf

File tree

6 files changed

+347
-238
lines changed

6 files changed

+347
-238
lines changed

crates/oxc_angular_compiler/src/pipeline/phases/attribute_extraction.rs

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -237,25 +237,6 @@ fn process_view_attributes<'a>(
237237
};
238238
extracted_attrs.push((twp_op.target, extracted));
239239
}
240-
UpdateOp::Control(control_op) => {
241-
// Control bindings (e.g. [field]="...") also generate extracted
242-
// attributes for directive matching, similar to Property bindings.
243-
// Ported from Angular's attribute_extraction.ts lines 58-73.
244-
let extracted = ExtractedAttributeOp {
245-
base: CreateOpBase::default(),
246-
target: control_op.target,
247-
binding_kind: BindingKind::Property,
248-
namespace: None,
249-
name: control_op.name.clone(),
250-
value: None, // Control bindings don't copy the expression
251-
security_context: control_op.security_context,
252-
truthy_expression: false,
253-
i18n_context: None,
254-
i18n_message: None,
255-
trusted_value_fn: None, // Set by resolve_sanitizers phase
256-
};
257-
extracted_attrs.push((control_op.target, extracted));
258-
}
259240
// StyleProp and ClassProp bindings:
260241
// In Angular TypeScript, these are only extracted in compatibility mode
261242
// (TemplateDefinitionBuilder) when the expression is empty. We don't support

crates/oxc_angular_compiler/src/pipeline/phases/binding_specialization.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
//! Special cases:
1717
//! - `ngNonBindable` attribute: marks element and removes binding
1818
//! - `animate.*` attributes: convert to animation bindings
19-
//! - `field` property: convert to control binding
19+
//! - `formField` property: emit both a regular property binding and a control binding
2020
//!
2121
//! Ported from Angular's `template/pipeline/src/phases/binding_specialization.ts`.
2222
@@ -301,20 +301,35 @@ fn specialize_in_view<'a>(
301301
cursor.replace_current(new_op);
302302
}
303303
} else if name.as_str() == "formField" {
304-
// Check for special "formField" property (control binding)
304+
// [formField] still binds as a regular property, but Angular also emits
305+
// a separate control instruction after the property update.
305306
if let Some(UpdateOp::Binding(binding)) = cursor.current_mut() {
306307
let expression = std::mem::replace(
307308
&mut binding.expression,
308309
create_placeholder_expression(allocator),
309310
);
310-
let new_op = UpdateOp::Control(ControlOp {
311+
let property_op = UpdateOp::Property(PropertyOp {
311312
base: UpdateOpBase { source_span, ..Default::default() },
312313
target,
313314
name: binding.name.clone(),
314315
expression,
316+
is_host: false, // Template mode
317+
security_context,
318+
sanitizer: None,
319+
is_structural: false,
320+
i18n_context: None,
321+
i18n_message: binding.i18n_message,
322+
binding_kind,
323+
});
324+
let control_op = UpdateOp::Control(ControlOp {
325+
base: UpdateOpBase { source_span, ..Default::default() },
326+
target,
327+
name: binding.name.clone(),
328+
expression: create_placeholder_expression(allocator),
315329
security_context,
316330
});
317-
cursor.replace_current(new_op);
331+
cursor.replace_current(property_op);
332+
cursor.insert_after(control_op);
318333
}
319334
} else {
320335
// Regular property binding

crates/oxc_angular_compiler/src/pipeline/phases/reify/mod.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,10 +1073,7 @@ fn reify_update_op<'a>(
10731073
let expr = convert_ir_expression(allocator, &anim.expression, expressions, root_xref);
10741074
Some(create_animation_binding_stmt(allocator, &anim.name, expr))
10751075
}
1076-
UpdateOp::Control(ctrl) => {
1077-
let expr = convert_ir_expression(allocator, &ctrl.expression, expressions, root_xref);
1078-
Some(create_control_stmt(allocator, expr, &ctrl.name))
1079-
}
1076+
UpdateOp::Control(_) => Some(create_control_stmt(allocator)),
10801077
UpdateOp::Variable(var) => {
10811078
// Emit variable declaration with initializer for update phase
10821079
// All Variable ops use `const` (StmtModifier::Final), matching Angular's reify.ts

crates/oxc_angular_compiler/src/pipeline/phases/reify/statements/misc.rs

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -377,26 +377,11 @@ pub fn create_animation_binding_stmt<'a>(
377377

378378
/// Creates a control binding call statement (ɵɵcontrol).
379379
///
380-
/// The control instruction takes:
381-
/// - expression: The expression to evaluate for the control value
382-
/// - name: The property name as a string literal
383-
/// - sanitizer: Optional sanitizer (only if not null)
384-
///
385-
/// Note: Unlike property() which takes (name, expression), control() takes (expression, name).
386-
/// Ported from Angular's `control()` in `instruction.ts` lines 598-614.
387-
pub fn create_control_stmt<'a>(
388-
allocator: &'a oxc_allocator::Allocator,
389-
value: OutputExpression<'a>,
390-
name: &Ident<'a>,
391-
) -> OutputStatement<'a> {
392-
let mut args = OxcVec::new_in(allocator);
393-
args.push(value);
394-
args.push(OutputExpression::Literal(Box::new_in(
395-
LiteralExpr { value: LiteralValue::String(name.clone()), source_span: None },
396-
allocator,
397-
)));
398-
// Note: sanitizer would be pushed here if not null, but it's always null for ControlOp
399-
create_instruction_call_stmt(allocator, Identifiers::CONTROL, args)
380+
/// Angular's control update instruction takes no arguments. The `[formField]`
381+
/// value is written through the regular property instruction, and `ɵɵcontrol()`
382+
/// performs the form-control synchronization work separately.
383+
pub fn create_control_stmt<'a>(allocator: &'a oxc_allocator::Allocator) -> OutputStatement<'a> {
384+
create_instruction_call_stmt(allocator, Identifiers::CONTROL, OxcVec::new_in(allocator))
400385
}
401386

402387
/// Creates an ɵɵprojectionDef() call statement from a pre-built R3 def expression.

crates/oxc_angular_compiler/src/pipeline/phases/var_counting.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,10 @@ fn vars_used_by_update_op(
230230
1
231231
}
232232
UpdateOp::Control(_) => {
233-
// Control bindings use 2 slots (one for value, one for bound states)
234-
2
233+
// Angular's ControlOp does not implement ConsumesVarsTrait, so it does not
234+
// contribute any top-level variable slots. The bound [formField] value is
235+
// accounted for by the PropertyOp that precedes it.
236+
0
235237
}
236238
UpdateOp::Conditional(_) => 1,
237239
UpdateOp::StoreLet(_) => 1,

0 commit comments

Comments
 (0)