From 7b762b1a475458b0883ce1bd7d86866a8a712fc0 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Fri, 13 Feb 2026 14:49:37 -0800 Subject: [PATCH 1/2] test(forms): `[formField]` synchronizes with a host directive Test that `[formField]` synchronizes its value with a custom form control implemented as a host directive on a component. --- .../test/web/form_field_directive.spec.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/forms/signals/test/web/form_field_directive.spec.ts b/packages/forms/signals/test/web/form_field_directive.spec.ts index 5477058f1d78..b14e9c9af42a 100644 --- a/packages/forms/signals/test/web/form_field_directive.spec.ts +++ b/packages/forms/signals/test/web/form_field_directive.spec.ts @@ -2881,6 +2881,60 @@ describe('field directive', () => { expect(cmp.f().value()).toBe('typing'); }); + it('synchronizes with a custom value control host directive', () => { + @Directive() + class CustomInputDirective implements FormValueControl { + readonly value = model(''); + } + + @Component({ + selector: 'my-input', + hostDirectives: [ + { + directive: CustomInputDirective, + inputs: ['value'], + outputs: ['valueChange'], + }, + ], + template: ` + + `, + }) + class CustomInput { + readonly control = inject(CustomInputDirective); + } + + @Component({ + imports: [CustomInput, FormField], + template: ``, + }) + class TestCmp { + f = form(signal('test')); + } + + const fix = act(() => TestBed.createComponent(TestCmp)); + const input = fix.nativeElement.querySelector('input') as HTMLInputElement; + const cmp = fix.componentInstance as TestCmp; + + // Initial state + expect(input.value).toBe('test'); + + // Model -> View + act(() => cmp.f().value.set('testing')); + expect(input.value).toBe('testing'); + + // View -> Model + act(() => { + input.value = 'typing'; + input.dispatchEvent(new Event('input')); + }); + expect(cmp.f().value()).toBe('typing'); + }); + it('synchronizes with a custom value control with separate input and output properties', () => { @Component({ selector: 'my-input', From 7904ec34b08d2ee812fa5d15302383eae1e49a00 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Fri, 13 Feb 2026 16:15:25 -0800 Subject: [PATCH 2/2] fix(forms): support custom controls as host directives Add the missing code to update control properties when control is a host directive. Fix #66592. --- .../core/src/render3/instructions/control.ts | 25 +- .../test/web/form_field_directive.spec.ts | 475 ++++++++++++++++++ 2 files changed, 495 insertions(+), 5 deletions(-) diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index 4c2e80825f23..193608519c29 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -160,14 +160,29 @@ class ControlDirectiveHostImpl implements ControlDirectiveHost { setInputOnDirectives(inputName: string, value: unknown): boolean { const directiveIndices = this.tNode.inputs?.[inputName]; - if (!directiveIndices) { + const hostDirectiveInputs = this.tNode.hostDirectiveInputs?.[inputName]; + if (!directiveIndices && !hostDirectiveInputs) { return false; } - for (const index of directiveIndices) { - const directiveDef = this.tView.data[index] as DirectiveDef; - const directive = this.lView[index]; - writeToDirectiveInput(directiveDef, directive, inputName, value); + + if (directiveIndices) { + for (const index of directiveIndices) { + const directiveDef = this.tView.data[index] as DirectiveDef; + const directive = this.lView[index]; + writeToDirectiveInput(directiveDef, directive, inputName, value); + } + } + + if (hostDirectiveInputs) { + for (let i = 0; i < hostDirectiveInputs.length; i += 2) { + const index = hostDirectiveInputs[i] as number; + const internalName = hostDirectiveInputs[i + 1] as string; + const directiveDef = this.tView.data[index] as DirectiveDef; + const directive = this.lView[index]; + writeToDirectiveInput(directiveDef, directive, internalName, value); + } } + return true; } diff --git a/packages/forms/signals/test/web/form_field_directive.spec.ts b/packages/forms/signals/test/web/form_field_directive.spec.ts index b14e9c9af42a..d76c03dc7ea5 100644 --- a/packages/forms/signals/test/web/form_field_directive.spec.ts +++ b/packages/forms/signals/test/web/form_field_directive.spec.ts @@ -241,6 +241,42 @@ describe('field directive', () => { expect(input.disabled).toBe(true); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(false); + readonly disabled = input(false); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['disabled', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly disabled = signal(false); + readonly f = form(signal(false), (p) => { + disabled(p, this.disabled); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().disabled()).toBe(false); + + act(() => component.disabled.set(true)); + expect(component.customControl().disabled()).toBe(true); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -407,6 +443,46 @@ describe('field directive', () => { }); describe('disabledReasons', () => { + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model.required(); + readonly disabledReasons = + input.required[]>(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + { + directive: CustomControlDir, + inputs: ['disabledReasons', 'value'], + outputs: ['valueChange'], + }, + ], + }) + class CustomControl {} + + @Component({ + template: ` `, + imports: [CustomControl, FormField], + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data, (p) => { + disabled(p, () => 'Currently unavailable'); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + + expect(comp.customControl().disabledReasons()).toEqual([ + {message: 'Currently unavailable', fieldTree: comp.f}, + ]); + }); + it('should bind to custom control', () => { @Component({ selector: 'custom-control', @@ -542,6 +618,41 @@ describe('field directive', () => { }); describe('errors', () => { + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model.required(); + readonly errors = input.required[]>(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['errors', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + template: ` `, + imports: [CustomControl, FormField], + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data, (p) => { + required(p); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.customControl().errors()).toEqual([requiredError({fieldTree: comp.f})]); + + act(() => comp.f().value.set('valid')); + expect(comp.customControl().errors()).toEqual([]); + }); + it('should bind to custom control', () => { @Component({ selector: 'custom-control', @@ -665,6 +776,44 @@ describe('field directive', () => { }); describe('hidden', () => { + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model.required(); + readonly hidden = input.required(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['hidden', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + const visible = signal(false); + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal(''), (p) => { + hidden(p, () => !visible()); + }); + readonly field = signal(this.f); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().hidden()).toBe(true); + + act(() => visible.set(true)); + expect(component.customControl().hidden()).toBe(false); + }); + it('should bind to a custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -827,6 +976,40 @@ describe('field directive', () => { }); describe('invalid', () => { + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model.required(); + readonly invalid = input.required(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['invalid', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + template: ` `, + imports: [CustomControl, FormField], + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data, (p) => { + required(p); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.customControl().invalid()).toBe(true); + act(() => comp.f().value.set('valid')); + expect(comp.customControl().invalid()).toBe(false); + }); + it('should bind to custom control', () => { @Component({ selector: 'custom-control', @@ -966,6 +1149,46 @@ describe('field directive', () => { expect(control1.name).toBe('root.1'); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(''); + readonly name = input(''); + } + + @Component({ + selector: 'custom-control', + template: '{{ control.value() }}', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['name', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl { + control = inject(CustomControlDir); + } + + @Component({ + imports: [FormField, CustomControl], + template: ` + @for (item of f; track item) { + + } + `, + }) + class TestCmp { + readonly f = form(signal(['a', 'b']), {name: 'root'}); + readonly controls = viewChildren(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + const control0 = component.controls()[0]; + const control1 = component.controls()[1]; + expect(control0.name()).toBe('root.0'); + expect(control1.name()).toBe('root.1'); + expect(fixture.nativeElement.innerText).toBe('ab'); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: `{{ value() }}`}) class CustomControl implements FormValueControl { @@ -1262,6 +1485,42 @@ describe('field directive', () => { expect(element.readOnly).toBe(false); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(''); + readonly readonly = input(false); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['readonly', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly readonly = signal(false); + readonly f = form(signal(''), (p) => { + readonly(p, this.readonly); + }); + readonly child = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.child().readonly()).toBe(false); + + act(() => component.readonly.set(true)); + expect(component.child().readonly()).toBe(true); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -1447,6 +1706,42 @@ describe('field directive', () => { expect(element.required).toBe(true); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(''); + readonly required = input(false); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['required', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly required = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.required}); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().required()).toBe(false); + + act(() => component.required.set(true)); + expect(component.customControl().required()).toBe(true); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -1632,6 +1927,42 @@ describe('field directive', () => { expect(element.max).toBe('5'); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(0); + readonly max = input(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['max', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly max = signal(10); + readonly f = form(signal(5), (p) => { + max(p, this.max); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().max()).toBe(10); + + act(() => component.max.set(5)); + expect(component.customControl().max()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -1817,6 +2148,42 @@ describe('field directive', () => { expect(element.min).toBe('5'); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(0); + readonly min = input(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['min', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly min = signal(10); + readonly f = form(signal(15), (p) => { + min(p, this.min); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().min()).toBe(10); + + act(() => component.min.set(5)); + expect(component.customControl().min()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2002,6 +2369,42 @@ describe('field directive', () => { expect(element.maxLength).toBe(15); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(''); + readonly maxLength = input(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['maxLength', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly maxLength = signal(10); + readonly f = form(signal(''), (p) => { + maxLength(p, this.maxLength); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().maxLength()).toBe(10); + + act(() => component.maxLength.set(5)); + expect(component.customControl().maxLength()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2203,6 +2606,42 @@ describe('field directive', () => { expect(element.minLength).toBe(15); }); + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(''); + readonly minLength = input(); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['minLength', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly minLength = signal(10); + readonly f = form(signal(''), (p) => { + minLength(p, this.minLength); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().minLength()).toBe(10); + + act(() => component.minLength.set(5)); + expect(component.customControl().minLength()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2384,6 +2823,42 @@ describe('field directive', () => { }); describe('pattern', () => { + it('should bind to a custom control host directive', () => { + @Directive() + class CustomControlDir implements FormValueControl { + readonly value = model(''); + readonly pattern = input([]); + } + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [ + {directive: CustomControlDir, inputs: ['pattern', 'value'], outputs: ['valueChange']}, + ], + }) + class CustomControl {} + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly pattern = signal(/abc/); + readonly f = form(signal(''), (p) => { + pattern(p, this.pattern); + }); + readonly customControl = viewChild.required(CustomControlDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().pattern()).toEqual([/abc/]); + + act(() => component.pattern.set(/def/)); + expect(component.customControl().pattern()).toEqual([/def/]); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl {