From ff37a13913353366feecfbb8bb161ee4f4c8e0b6 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Tue, 23 Dec 2025 15:28:25 -0800 Subject: [PATCH] refactor(forms): bind field properties to all directives on custom controls The framework will now bind the field state properties to all matching directive inputs on custom form controls. (cherry picked from commit 0c8f15d5466dceb3020345919cd8e17846399504) --- .../core/src/render3/instructions/control.ts | 53 +- .../signals/test/web/field_directive.spec.ts | 516 ++++++++++++++++++ 2 files changed, 534 insertions(+), 35 deletions(-) diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index eed6ab46297..12f7f681b73 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -568,45 +568,28 @@ function updateCustomControl( const state = control.state(); const bindings = getControlBindings(lView); - maybeUpdateInput(directiveDef, directive, bindings, state, CONTROL_VALUE, modelName); + // Bind custom form control model ('value' or 'checked'). + const controlValue = state.controlValue(); + if (controlBindingUpdated(bindings, CONTROL_VALUE, controlValue)) { + writeToDirectiveInput(directiveDef, directive, modelName, controlValue); + } + const isNative = (tNode.flags & TNodeFlags.isNativeControl) !== 0; + const element = isNative ? (getNativeByTNode(tNode, lView) as NativeControlElement) : null; + const renderer = lView[RENDERER]; + + // Bind remaining field state properties. for (const key of CONTROL_BINDING_KEYS) { - const inputName = CONTROL_BINDING_NAMES[key]; - maybeUpdateInput(directiveDef, directive, bindings, state, key, inputName); - } - - // If the host node is a native control, we can bind field state properties to attributes for any - // that weren't defined as inputs on the custom control. We can reuse the update path for native - // controls since any properties with a corresponding input would have just been checked above, - // and thus will appear unchanged. - if (tNode.flags & TNodeFlags.isNativeControl) { - updateNativeControl(tNode, lView, control); - } -} - -/** - * Binds a value from the field state to a component input, if the input exists and the value has - * changed. - * - * @param componentDef The component definition used to check for the input. - * @param component The component instance to update. - * @param bindings A map of previously bound values to check for changes. - * @param state The control's field state. - * @param key The key of the property in the `ɵFieldState` to bind. - * @param inputName The name of the input to update. - */ -function maybeUpdateInput( - directiveDef: DirectiveDef, - directive: unknown, - bindings: ControlBindings, - state: ɵFieldState, - key: ControlBindingKeys, - inputName: string, -): void { - if (inputName in directiveDef.inputs) { const value = state[key]?.(); if (controlBindingUpdated(bindings, key, value)) { - writeToDirectiveInput(directiveDef, directive, inputName, value); + const inputName = CONTROL_BINDING_NAMES[key]; + updateDirectiveInputs(tNode, lView, inputName, value); + + // If the host node is a native control, we can bind field state properties to native + // properties for any that weren't defined as inputs on the custom control. + if (isNative && !(inputName in directiveDef.inputs)) { + updateNativeProperty(tNode, renderer, element!, key, value, inputName); + } } } } diff --git a/packages/forms/signals/test/web/field_directive.spec.ts b/packages/forms/signals/test/web/field_directive.spec.ts index c03b68f2599..0672f4d5e01 100644 --- a/packages/forms/signals/test/web/field_directive.spec.ts +++ b/packages/forms/signals/test/web/field_directive.spec.ts @@ -162,6 +162,36 @@ describe('field directive', () => { expect(comp.dir().dirty()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly dirty = input.required(); + } + + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data); + readonly dir = viewChild.required(TestDir); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.dir().dirty()).toBe(false); + act(() => comp.f().markAsDirty()); + expect(comp.dir().dirty()).toBe(true); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -292,6 +322,41 @@ describe('field directive', () => { expect(dir.disabled()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly disabled = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(false); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly disabled = signal(false); + readonly f = form(signal(false), (p) => { + disabled(p, this.disabled); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.disabled()).toBe(false); + expect(element.disabled).toBe(false); + + act(() => fixture.componentInstance.disabled.set(true)); + expect(dir.disabled()).toBe(true); + expect(element.disabled).toBe(true); + }); + it('should be reset when field changes on native control', () => { @Component({ imports: [Field], @@ -402,6 +467,45 @@ describe('field directive', () => { ]); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly disabledReasons = input.required[]>(); + } + + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly disabled = signal(false); + readonly f = form(signal(''), (p) => { + disabled(p, () => { + return this.disabled() ? 'b' : false; + }); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.disabledReasons()).toEqual([]); + + act(() => fixture.componentInstance.disabled.set(true)); + expect(dir.disabledReasons()).toEqual([ + {message: 'b', fieldTree: fixture.componentInstance.f}, + ]); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -490,6 +594,41 @@ describe('field directive', () => { expect(dir.errors()).toEqual([requiredError({fieldTree: fixture.componentInstance.f})]); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly errors = input.required[]>(); + } + + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly required = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.required}); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.errors()).toEqual([]); + + act(() => fixture.componentInstance.required.set(true)); + expect(dir.errors()).toEqual([requiredError({fieldTree: fixture.componentInstance.f})]); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -578,6 +717,38 @@ describe('field directive', () => { expect(dir.hidden()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly hidden = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly hidden = signal(false); + readonly f = form(signal(''), (p) => { + hidden(p, this.hidden); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.hidden()).toBe(false); + + act(() => fixture.componentInstance.hidden.set(true)); + expect(dir.hidden()).toBe(true); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -662,6 +833,38 @@ describe('field directive', () => { expect(dir.invalid()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly invalid = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly invalid = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.invalid}); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.invalid()).toBe(false); + + act(() => fixture.componentInstance.invalid.set(true)); + expect(dir.invalid()).toBe(true); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -787,6 +990,34 @@ describe('field directive', () => { expect(dir.name()).toBe('root'); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly name = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal(''), {name: 'root'}); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.name()).toBe('root'); + expect(element.name).toBe('root'); + }); }); describe('pending', () => { @@ -916,6 +1147,49 @@ describe('field directive', () => { await fixture.whenStable(); expect(dir.pending()).toBe(false); }); + + it('should bind to directive input on custom control', async () => { + const {promise, resolve} = promiseWithResolvers(); + + @Directive({selector: '[testDir]'}) + class TestDir { + readonly pending = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal(''), (p) => { + validateAsync(p, { + params: () => [], + factory: (params) => + resource({ + params, + loader: () => promise, + }), + onSuccess: (results) => results, + onError: () => null, + }); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.pending()).toBe(true); + resolve([]); + await promise; + await fixture.whenStable(); + expect(dir.pending()).toBe(false); + }); }); describe('readonly', () => { @@ -1019,6 +1293,41 @@ describe('field directive', () => { expect(dir.readonly()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly readonly = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly readonly = signal(false); + readonly f = form(signal(''), (p) => { + readonly(p, this.readonly); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.readonly()).toBe(false); + expect(element.readOnly).toBe(false); + + act(() => fixture.componentInstance.readonly.set(true)); + expect(dir.readonly()).toBe(true); + expect(element.readOnly).toBe(true); + }); + it('should be reset when field changes on native control', () => { @Component({ imports: [Field], @@ -1169,6 +1478,41 @@ describe('field directive', () => { expect(dir.required()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly required = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly required = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.required}); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.required()).toBe(false); + expect(element.required).toBe(false); + + act(() => fixture.componentInstance.required.set(true)); + expect(dir.required()).toBe(true); + expect(element.required).toBe(true); + }); + it('should be reset when field changes on native control', () => { @Component({ imports: [Field], @@ -1366,6 +1710,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.max.set(5)); expect(dir.max()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly max = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(0); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly max = signal(10); + readonly f = form(signal(5), (p) => { + max(p, this.max); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.max()).toBe(10); + expect(element.max).toBe('10'); + + act(() => fixture.componentInstance.max.set(5)); + expect(dir.max()).toBe(5); + expect(element.max).toBe('5'); + }); }); describe('min', () => { @@ -1516,6 +1895,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.min.set(5)); expect(dir.min()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly min = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(0); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly min = signal(10); + readonly f = form(signal(15), (p) => { + min(p, this.min); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.min()).toBe(10); + expect(element.min).toBe('10'); + + act(() => fixture.componentInstance.min.set(5)); + expect(dir.min()).toBe(5); + expect(element.min).toBe('5'); + }); }); describe('maxLength', () => { @@ -1682,6 +2096,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.maxLength.set(5)); expect(dir.maxLength()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly maxLength = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly maxLength = signal(10); + readonly f = form(signal(''), (p) => { + maxLength(p, this.maxLength); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.maxLength()).toBe(10); + expect(element.maxLength).toBe(10); + + act(() => fixture.componentInstance.maxLength.set(5)); + expect(dir.maxLength()).toBe(5); + expect(element.maxLength).toBe(5); + }); }); describe('minLength', () => { @@ -1848,6 +2297,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.minLength.set(5)); expect(dir.minLength()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly minLength = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly minLength = signal(10); + readonly f = form(signal('abc'), (p) => { + minLength(p, this.minLength); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.minLength()).toBe(10); + expect(element.minLength).toBe(10); + + act(() => fixture.componentInstance.minLength.set(5)); + expect(dir.minLength()).toBe(5); + expect(element.minLength).toBe(5); + }); }); describe('pattern', () => { @@ -1931,6 +2415,38 @@ describe('field directive', () => { act(() => fixture.componentInstance.pattern.set(/b*/)); expect(dir.pattern()).toEqual([/b*/]); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly pattern = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly pattern = signal(/a*/); + readonly f = form(signal('abc'), (p) => { + pattern(p, this.pattern); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.pattern()).toEqual([/a*/]); + + act(() => fixture.componentInstance.pattern.set(/b*/)); + expect(dir.pattern()).toEqual([/b*/]); + }); }); });