fix(forms): Ensure the control instruction comes after the other bindings

Prior to this change, binding to radio value was sensitive to the order in which `value` & `formField` where binding in the template.
The compiler change makes that order non-important.

fixes #66402
This commit is contained in:
Matthieu Riegler
2026-01-08 16:49:43 +01:00
committed by Jessica Janiuk
parent 7fd076bdd3
commit 65fa5b5439
6 changed files with 196 additions and 0 deletions
@@ -48,3 +48,74 @@ export declare class MyComponent {
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
}
/****************************************************************************************************
* PARTIAL FILE: radio_bindings.js
****************************************************************************************************/
import { Component, Directive, input } from '@angular/core';
import * as i0 from "@angular/core";
export class FormField {
formField = input(...(ngDevMode ? [undefined, { debugName: "formField" }] : []));
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FormField, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "0.0.0-PLACEHOLDER", type: FormField, isStandalone: true, selector: "[formField]", inputs: { formField: { classPropertyName: "formField", publicName: "formField", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: FormField, decorators: [{
type: Directive,
args: [{ selector: '[formField]' }]
}], propDecorators: { formField: [{ type: i0.Input, args: [{ isSignal: true, alias: "formField", required: false }] }] } });
// Notice that we check that the binding order doesn't matter
export class MyComponent {
value = 'foo';
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyComponent, isStandalone: true, selector: "ng-component", ngImport: i0, template: `
<input
type="radio"
[formField]="value"
[value]="'foo'"
id="radio"
/>
<input
type="radio"
[value]="'foo'"
[formField]="value"
id="radio"
/>
`, isInline: true, dependencies: [{ kind: "directive", type: FormField, selector: "[formField]", inputs: ["formField"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyComponent, decorators: [{
type: Component,
args: [{
template: `
<input
type="radio"
[formField]="value"
[value]="'foo'"
id="radio"
/>
<input
type="radio"
[value]="'foo'"
[formField]="value"
id="radio"
/>
`,
imports: [FormField],
}]
}] });
/****************************************************************************************************
* PARTIAL FILE: radio_bindings.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class FormField {
readonly formField: import("@angular/core").InputSignal<string | undefined>;
static ɵfac: i0.ɵɵFactoryDeclaration<FormField, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<FormField, "[formField]", never, { "formField": { "alias": "formField"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
}
export declare class MyComponent {
value: string;
static ɵfac: i0.ɵɵFactoryDeclaration<MyComponent, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyComponent, "ng-component", never, {}, {}, never, never, true, never>;
}
@@ -9,6 +9,15 @@
"files": ["control_bindings.js"]
}
]
},
{
"description": "should generate control instruction for `field` property bindings on radio inputs and execute it after the value",
"inputFiles": ["radio_bindings.ts"],
"expectations": [
{
"files": ["radio_bindings.js"]
}
]
}
]
}
@@ -0,0 +1,27 @@
export class MyComponent {
// ...
static ɵcmp = /* @__PURE__ */i0.ɵɵdefineComponent({
type: MyComponent,
selectors: [["ng-component"]],
decls: 2,
vars: 6,
consts: [["type", "radio", "id", "radio", 3, "formField", "value"], ["type", "radio", "id", "radio", 3, "value", "formField"]],
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelement(0, "input", 0);
i0.ɵɵcontrolCreate();
i0.ɵɵelement(1, "input", 1);
i0.ɵɵcontrolCreate();
}
if (rf & 2) {
i0.ɵɵproperty("value", "foo");
i0.ɵɵcontrol(ctx.value, "formField");
i0.ɵɵadvance();
i0.ɵɵproperty("value", "foo");
i0.ɵɵcontrol(ctx.value, "formField");
}
},
dependencies: [FormField],
encapsulation: 2
});
}
@@ -0,0 +1,30 @@
import { Component, Directive, input } from '@angular/core';
@Directive({selector: '[formField]'})
export class FormField {
readonly formField = input<string>();
}
// Notice that we check that the binding order doesn't matter
@Component({
template: `
<input
type="radio"
[formField]="value"
[value]="'foo'"
id="radio"
/>
<input
type="radio"
[value]="'foo'"
[formField]="value"
id="radio"
/>
`,
imports: [FormField],
})
export class MyComponent {
value = 'foo';
}
@@ -66,6 +66,7 @@ const UPDATE_ORDERING: Array<Rule<ir.UpdateOp>> = [
{test: kindWithInterpolationTest(ir.OpKind.Property, true)},
{test: nonInterpolationPropertyKindTest},
{test: kindWithInterpolationTest(ir.OpKind.Attribute, false)},
{test: kindTest(ir.OpKind.Control)},
];
/**
@@ -97,6 +98,7 @@ const handledOpKinds = new Set([
ir.OpKind.DomProperty,
ir.OpKind.Attribute,
ir.OpKind.Animation,
ir.OpKind.Control,
]);
/**
@@ -2667,6 +2667,30 @@ describe('field directive', () => {
expect(cmp.f().value()).toBe('b');
});
it('synchronizes with a radio group with bindings', () => {
const {cmp, inputA, inputB, inputC, ABC} = setupRadioWithBindingsGroup();
// All the inputs should have the same name.
expect(inputA.name).toBe('test');
expect(inputB.name).toBe('test');
expect(inputC.name).toBe('test');
// Model -> View
act(() => cmp.f().value.set(ABC.C));
expect(inputA.checked).toBe(false);
expect(inputB.checked).toBe(false);
expect(inputC.checked).toBe(true);
// View -> Model
act(() => {
inputB.click();
});
expect(inputA.checked).toBe(false);
expect(inputB.checked).toBe(true);
expect(inputC.checked).toBe(false);
expect(cmp.f().value()).toBe(ABC.B);
});
it('synchronizes with a textarea', () => {
@Component({
imports: [FormField],
@@ -4321,6 +4345,39 @@ function setupRadioGroup() {
return {cmp, inputA, inputB, inputC};
}
function setupRadioWithBindingsGroup() {
enum ABC {
A = 'a',
B = 'b',
C = 'c',
}
@Component({
imports: [FormField],
template: `
<form>
<input type="radio" [formField]="f" [value]="ABC.A" />
<input type="radio" [formField]="f" [value]="ABC.B" />
<input type="radio" [formField]="f" [value]="ABC.C" />
</form>
`,
})
class TestCmp {
f = form(signal(ABC.A), {
name: 'test',
});
ABC = ABC;
}
const fix = act(() => TestBed.createComponent(TestCmp));
const formEl = (fix.nativeElement as HTMLElement).firstChild as HTMLFormElement;
const inputs = Array.from(formEl.children) as HTMLInputElement[];
const [inputA, inputB, inputC] = inputs;
const cmp = fix.componentInstance as TestCmp;
return {cmp, inputA, inputB, inputC, ABC};
}
function act<T>(fn: () => T): T {
try {
return fn();