From fd9af2afaf6c239bbbba50f2f016ecf9b83133c4 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Wed, 15 Oct 2025 10:47:15 -0700 Subject: [PATCH] fix(forms): only propagate schema defined properties from field to control (#64446) Prior to this change, `FieldState` defined a signal for each built-in property. This unfortunately meant that the `Field` directive had no way of knowing which property had actually been defined in the schema, and would thus attempt to propagate them all to the bound form control. This meant that the default values of these signals would override the default or template defined values of these control properties. Now these properties are `undefined` by default, and only initialized if defined in the schema. Thus the `Field` directive will not attempt to bind any properties that aren't explicitly managed by the schema. PR Close #64446 --- .../core/src/render3/instructions/control.ts | 32 ++- .../core/src/render3/interfaces/control.ts | 12 +- packages/forms/signals/src/field/node.ts | 30 +- packages/forms/signals/src/field/property.ts | 2 +- packages/forms/signals/src/schema/logic.ts | 2 +- .../signals/test/web/field_directive.spec.ts | 260 ++++++++++++++++-- 6 files changed, 289 insertions(+), 49 deletions(-) diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index 38fe5c53ff5..8e640d60e49 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -347,15 +347,16 @@ function updateCustomControl( maybeWriteToDirectiveInput(componentDef, component, 'invalid', state.invalid); maybeWriteToDirectiveInput(componentDef, component, 'disabled', state.disabled); maybeWriteToDirectiveInput(componentDef, component, 'disabledReasons', state.disabledReasons); + maybeWriteToDirectiveInput(componentDef, component, 'name', state.name); + maybeWriteToDirectiveInput(componentDef, component, 'readonly', state.readonly); + maybeWriteToDirectiveInput(componentDef, component, 'touched', state.touched); + maybeWriteToDirectiveInput(componentDef, component, 'max', state.max); maybeWriteToDirectiveInput(componentDef, component, 'maxLength', state.maxLength); maybeWriteToDirectiveInput(componentDef, component, 'min', state.min); maybeWriteToDirectiveInput(componentDef, component, 'minLength', state.minLength); - maybeWriteToDirectiveInput(componentDef, component, 'name', state.name); maybeWriteToDirectiveInput(componentDef, component, 'pattern', state.pattern); - maybeWriteToDirectiveInput(componentDef, component, 'readonly', state.readonly); maybeWriteToDirectiveInput(componentDef, component, 'required', state.required); - maybeWriteToDirectiveInput(componentDef, component, 'touched', state.touched); } /** @@ -370,9 +371,9 @@ function maybeWriteToDirectiveInput( componentDef: ComponentDef, component: unknown, inputName: string, - source: () => unknown, + source?: () => unknown, ) { - if (inputName in componentDef.inputs) { + if (source && inputName in componentDef.inputs) { writeToDirectiveInput(componentDef, component, inputName, source()); } } @@ -395,16 +396,27 @@ function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl { * * Applies to `` with a numeric or date `type` attribute and custom controls. */ - readonly max: Signal; + readonly max?: Signal; /** * A signal indicating the field's maximum string length, if applicable. * * Applies to ``, ``, @@ -455,7 +582,7 @@ describe('field directive', () => { expect(element.maxLength).toBe(15); }); - it('custom control', () => { + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { readonly value = model(''); @@ -497,10 +624,47 @@ describe('field directive', () => { const element = fixture.nativeElement.firstChild as HTMLSelectElement; expect(element.getAttribute('maxLength')).toBeNull(); }); + + it('should not bind to native control by default', () => { + @Component({ + imports: [Field], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLTextAreaElement; + expect(element.maxLength).withContext("'maxLength' should be unchanged").toBe(123); + }); + + it('should not bind to custom control by default', () => { + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly maxLength = input(123); + } + + @Component({ + imports: [Field, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().maxLength()) + .withContext("'maxLength' should be unchanged") + .toBe(123); + }); }); describe('minLength', () => { - it('native control', () => { + it('should bind to native control', () => { @Component({ imports: [Field], template: ``, @@ -520,7 +684,7 @@ describe('field directive', () => { expect(element.minLength).toBe(15); }); - it('custom control', () => { + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { readonly value = model(''); @@ -547,7 +711,7 @@ describe('field directive', () => { expect(component.customControl().minLength()).toBe(5); }); - it('is not set on a native control that does not support it', () => { + it('should not bind to native control that does not support it', () => { @Component({ imports: [Field], template: ``, @@ -562,10 +726,47 @@ describe('field directive', () => { const element = fixture.nativeElement.firstChild as HTMLSelectElement; expect(element.getAttribute('minLength')).toBeNull(); }); + + it('should not bind to native control by default', () => { + @Component({ + imports: [Field], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const element = fixture.nativeElement.firstChild as HTMLTextAreaElement; + expect(element.minLength).withContext("'minLength' should be unchanged").toBe(123); + }); + + it('should not bind to custom control by default', () => { + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly minLength = input(123); + } + + @Component({ + imports: [Field, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().minLength()) + .withContext("'minLength' should be unchanged") + .toBe(123); + }); }); describe('pattern', () => { - it('custom control', () => { + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { readonly value = model(''); @@ -591,6 +792,29 @@ describe('field directive', () => { act(() => component.pattern.set(/def/)); expect(component.customControl().pattern()).toEqual([/def/]); }); + + it('should not bind to custom control by default', () => { + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly pattern = input([/abc/]); + } + + @Component({ + imports: [Field, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().pattern()) + .withContext("'pattern' should be unchanged") + .toEqual([/abc/]); + }); }); });