diff --git a/packages/forms/signals/src/directive/control_native.ts b/packages/forms/signals/src/directive/control_native.ts index aa6fcd412de..c21128eba6d 100644 --- a/packages/forms/signals/src/directive/control_native.ts +++ b/packages/forms/signals/src/directive/control_native.ts @@ -94,7 +94,7 @@ export function nativeControlCreate( ); } - const bindings = createBindings(); + const bindings = createBindings(); return () => { const state = parent.state(); @@ -117,7 +117,11 @@ export function nativeControlCreate( // We need to update the value after setting the attributes as some attributes like min/max might prevent from setting the value const controlValue = state.controlValue(); - if (bindingUpdated(bindings, 'controlValue', controlValue)) { + const controlValueChanged = bindingUpdated(bindings, 'controlValue', controlValue); + const radioValueChanged = + input.type === 'radio' && bindingUpdated(bindings, 'radioValue', input.value); + + if (controlValueChanged || radioValueChanged) { setNativeControlValue(input, controlValue); } diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 68714bac47d..150f0491132 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -4235,6 +4235,46 @@ describe('field directive', () => { expect(cmp.f().value()).toBe(ABC.B); }); + it('synchronizes the checked state when a reused radio changes value', async () => { + interface RadioOption { + readonly id: string; + readonly value: string; + } + + @Component({ + imports: [FormField], + template: ` + @for (option of options(); track option.id) { + + } + `, + }) + class TestCmp { + readonly f = form(signal('selected'), {name: 'test'}); + readonly options = signal>([ + {id: 'shared', value: 'other'}, + {id: 'old', value: 'selected'}, + ]); + } + + const fixture = TestBed.createComponent(TestCmp); + await fixture.whenStable(); + const getCheckedStates = () => + Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('input'), + ).map((input) => input.checked); + + expect(getCheckedStates()).toEqual([false, true]); + + fixture.componentInstance.options.set([ + {id: 'new', value: 'other'}, + {id: 'shared', value: 'selected'}, + ]); + await fixture.whenStable(); + + expect(getCheckedStates()).toEqual([false, true]); + }); + it('synchronizes with a textarea', () => { @Component({ imports: [FormField], diff --git a/packages/forms/src/directives/radio_control_value_accessor.ts b/packages/forms/src/directives/radio_control_value_accessor.ts index 61eeb7b445a..8b30d9735c5 100644 --- a/packages/forms/src/directives/radio_control_value_accessor.ts +++ b/packages/forms/src/directives/radio_control_value_accessor.ts @@ -17,6 +17,7 @@ import { OnInit, Provider, Renderer2, + SimpleChanges, ɵRuntimeError as RuntimeError, Service, } from '@angular/core'; @@ -179,6 +180,15 @@ export class RadioControlValueAccessor super(renderer, elementRef); } + /** @internal */ + ngOnChanges(changes: SimpleChanges): void { + const control = this._control?.control; + + if (changes['value'] && control) { + this.writeValue(control.value); + } + } + /** @docs-private */ ngOnInit(): void { this._control = this._injector.get(NgControl); diff --git a/packages/forms/test/value_accessor_integration_spec.ts b/packages/forms/test/value_accessor_integration_spec.ts index 5d502f5323c..8419f7c84c8 100644 --- a/packages/forms/test/value_accessor_integration_spec.ts +++ b/packages/forms/test/value_accessor_integration_spec.ts @@ -20,6 +20,7 @@ import { NgZone, Output, RendererFactory2, + signal, Type, ViewChild, } from '@angular/core'; @@ -660,6 +661,22 @@ describe('value accessors', () => { expect(inputs[1].nativeElement.checked).toEqual(true); }); + it('should update the checked state when a reused radio changes value', async () => { + const fixture = initTest(DynamicRadioForm); + await fixture.whenStable(); + + expect(getRadioCheckedStates(fixture)).toEqual([false, true]); + + fixture.componentInstance.form.patchValue({answer: 'selected'}); + fixture.componentInstance.options.set([ + {id: 'new', value: 'other'}, + {id: 'shared', value: 'selected'}, + ]); + await fixture.whenStable(); + + expect(getRadioCheckedStates(fixture)).toEqual([false, true]); + }); + it('should support an initial undefined value', () => { const fixture = initTest(FormControlRadioButtons); const form = new FormGroup({'food': new FormControl(), 'drink': new FormControl()}); @@ -2047,6 +2064,41 @@ export class FormControlRadioButtons { showRadio = new FormControl('yes'); } +interface RadioOption { + readonly id: string; + readonly value: string; +} + +@Component({ + selector: 'dynamic-radio-form', + template: ` +
+ @for (option of options(); track option.id) { + + } +
+ `, + standalone: false, + changeDetection: ChangeDetectionStrategy.Eager, +}) +class DynamicRadioForm { + readonly form = new FormGroup({ + answer: new FormControl('selected', {nonNullable: true}), + }); + readonly options = signal>([ + {id: 'shared', value: 'other'}, + {id: 'old', value: 'selected'}, + ]); +} + +function getRadioCheckedStates(fixture: ComponentFixture): boolean[] { + const element = fixture.nativeElement as HTMLElement; + + return Array.from(element.querySelectorAll('input')).map( + (input) => input.checked, + ); +} + @Component({ selector: 'ng-model-radio-form', template: `