fix(compiler-cli): do not flag custom control required inputs as missing when field is present

Adds some logic that won't report the `value` or `checked` inputs as missing when the `Field` directive is present since it will bind to the inputs implicitly.
This commit is contained in:
Kristiyan Kostadinov 2025-11-06 11:55:50 +01:00 committed by Andrew Kushnir
parent 4d0778529f
commit 165634264e
2 changed files with 94 additions and 21 deletions

View file

@ -1205,6 +1205,7 @@ class TcbDirectiveInputsOp extends TcbOp {
private scope: Scope,
private node: TmplAstTemplate | TmplAstElement | TmplAstComponent | TmplAstDirective,
private dir: TypeCheckableDirectiveMeta,
private ignoredRequiredInputs: Set<string> | null,
) {
super();
}
@ -1375,7 +1376,12 @@ class TcbDirectiveInputsOp extends TcbOp {
const missing: BindingPropertyName[] = [];
for (const input of this.dir.inputs) {
if (input.required && !seenRequiredInputs.has(input.classPropertyName)) {
if (
input.required &&
!seenRequiredInputs.has(input.classPropertyName) &&
(this.ignoredRequiredInputs === null ||
!this.ignoredRequiredInputs.has(input.bindingPropertyName))
) {
missing.push(input.bindingPropertyName);
}
}
@ -3120,7 +3126,22 @@ class Scope {
const directiveOp = this.getDirectiveOp(dir, node, allDirectiveMatches);
const dirIndex = this.opQueue.push(directiveOp) - 1;
dirMap.set(dir, dirIndex);
this.opQueue.push(new TcbDirectiveInputsOp(this.tcb, this, node, dir));
let ignoredRequiredInputs: Set<string> | null = null;
// The `Field` directive will bind implicitly to
// the relevant input so we don't need to check for it.
if (allDirectiveMatches.some(isFieldDirective)) {
const customFieldType = getCustomFieldDirectiveType(dir);
if (customFieldType === 'value') {
ignoredRequiredInputs = new Set(['value']);
} else if (customFieldType === 'checkbox') {
ignoredRequiredInputs = new Set(['checked']);
}
}
this.opQueue.push(new TcbDirectiveInputsOp(this.tcb, this, node, dir, ignoredRequiredInputs));
}
private getDirectiveOp(
@ -3130,10 +3151,7 @@ class Scope {
): TcbOp {
const dirRef = dir.ref as Reference<ClassDeclaration<ts.ClassDeclaration>>;
if (
dir.name === 'Field' &&
dirRef.bestGuessOwningModule?.specifier === '@angular/forms/signals'
) {
if (isFieldDirective(dir)) {
let customControl: {type: 'value' | 'checkbox'; meta: TypeCheckableDirectiveMeta} | null =
null;
@ -4066,22 +4084,25 @@ function getComponentTagName(node: TmplAstComponent): string {
return node.tagName || 'ng-component';
}
/** Determines the type of signal form field control (if any) from a directive's metadata. */
function getCustomFieldDirectiveType({
inputs,
outputs,
}: TypeCheckableDirectiveMeta): 'value' | 'checkbox' | null {
if (
inputs.getByBindingPropertyName('value')?.some((v) => v.isSignal) &&
outputs.hasBindingPropertyName('valueChange')
) {
return 'value';
}
function isFieldDirective(meta: TypeCheckableDirectiveMeta): boolean {
return (
meta.name === 'Field' && meta.ref.bestGuessOwningModule?.specifier === '@angular/forms/signals'
);
}
if (
inputs.getByBindingPropertyName('checked')?.some((v) => v.isSignal) &&
outputs.hasBindingPropertyName('checkedChange')
) {
function hasModel(name: string, meta: TypeCheckableDirectiveMeta): boolean {
return (
!!meta.inputs.getByBindingPropertyName(name)?.some((v) => v.isSignal) &&
meta.outputs.hasBindingPropertyName(name + 'Change')
);
}
function getCustomFieldDirectiveType(
meta: TypeCheckableDirectiveMeta,
): 'value' | 'checkbox' | null {
if (hasModel('value', meta)) {
return 'value';
} else if (hasModel('checked', meta)) {
return 'checkbox';
}

View file

@ -360,5 +360,57 @@ runInEachFileSystem(() => {
`The types of 'required[SIGNAL].transformFn' are incompatible between these types.`,
);
});
it('should not report `value` as a missing required input when the `Field` directive is present', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {Field, form, FormValueControl} from '@angular/forms/signals';
@Component({selector: 'custom-control', template: ''})
export class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
template: '<custom-control [field]="f"/>',
imports: [Field, CustomControl]
})
export class Comp {
f = form(signal(''));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should not report `checked` as a missing required input when the `Field` directive is present', () => {
env.write(
'test.ts',
`
import {Component, signal, model} from '@angular/core';
import {Field, form, FormCheckboxControl} from '@angular/forms/signals';
@Component({selector: 'custom-control', template: ''})
export class CustomControl implements FormCheckboxControl {
readonly checked = model.required<boolean>();
}
@Component({
template: '<custom-control [field]="f"/>',
imports: [Field, CustomControl]
})
export class Comp {
f = form(signal(false));
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
});
});