Skip to content

Commit 39c577b

Browse files
crisbetokirjs
authored andcommitted
fix(compiler-cli): do not type check native controls with ControlValueAccessor
Currently when we detect a `field` binding on a native element, we treat it as a built-in native control. This might not be the case if it's a pre-existing `ControlValueAccessor` relying on the CVA interop. These changes try to detect any CVA-like directive on the element and disable the additional type checking if there are any. Fixes #65468. (cherry picked from commit 6b8720d)
1 parent f0b3485 commit 39c577b

File tree

3 files changed

+105
-7
lines changed

3 files changed

+105
-7
lines changed

packages/compiler-cli/src/ngtsc/typecheck/src/ops/scope.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
CustomFieldType,
5656
getCustomFieldDirectiveType,
5757
isFieldDirective,
58+
isNativeField,
5859
TcbNativeFieldDirectiveTypeOp,
5960
} from './signal_forms';
6061
import {Reference} from '../../../imports';
@@ -772,13 +773,8 @@ export class Scope {
772773
const dirIndex = this.opQueue.push(directiveOp) - 1;
773774
dirMap.set(dir, dirIndex);
774775

775-
if (
776-
isFieldDirective(dir) &&
777-
node instanceof TmplAstElement &&
778-
(node.name === 'input' || node.name === 'select' || node.name === 'textarea') &&
779-
!allDirectiveMatches.some(getCustomFieldDirectiveType)
780-
) {
781-
this.opQueue.push(new TcbNativeFieldDirectiveTypeOp(this.tcb, this, node));
776+
if (isNativeField(dir, node, allDirectiveMatches)) {
777+
this.opQueue.push(new TcbNativeFieldDirectiveTypeOp(this.tcb, this, node as TmplAstElement));
782778
}
783779

784780
this.opQueue.push(new TcbDirectiveInputsOp(this.tcb, this, node, dir, customFieldType));

packages/compiler-cli/src/ngtsc/typecheck/src/ops/signal_forms.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
TmplAstDirective,
1919
TmplAstElement,
2020
TmplAstHostElement,
21+
TmplAstNode,
2122
TmplAstTemplate,
2223
} from '@angular/compiler';
2324
import ts from 'typescript';
@@ -314,6 +315,43 @@ export function getCustomFieldDirectiveType(
314315
return null;
315316
}
316317

318+
/** Determines if a directive usage is on a native field. */
319+
export function isNativeField(
320+
dir: TypeCheckableDirectiveMeta,
321+
node: TmplAstNode,
322+
allDirectiveMatches: TypeCheckableDirectiveMeta[],
323+
): boolean {
324+
// Only applies to the `Field` directive.
325+
if (!isFieldDirective(dir)) {
326+
return false;
327+
}
328+
329+
// Only applies to input, select and textarea elements.
330+
if (
331+
!(node instanceof TmplAstElement) ||
332+
(node.name !== 'input' && node.name !== 'select' && node.name !== 'textarea')
333+
) {
334+
return false;
335+
}
336+
337+
// Only applies if there are no custom fields or ControlValueAccessors.
338+
return allDirectiveMatches.every((meta) => {
339+
return getCustomFieldDirectiveType(meta) === null && !isControlValueAccessorLike(meta);
340+
});
341+
}
342+
343+
/**
344+
* Determines if a directive is shaped like a `ControlValueAccessor`. Note that this isn't
345+
* 100% reliable, because we don't know if the directive was actually provided at runtime.
346+
*/
347+
function isControlValueAccessorLike(meta: TypeCheckableDirectiveMeta): boolean {
348+
return (
349+
meta.publicMethods.has('writeValue') &&
350+
meta.publicMethods.has('registerOnChange') &&
351+
meta.publicMethods.has('registerOnTouched')
352+
);
353+
}
354+
317355
/** Checks whether a node has bindings that aren't supported on fields. */
318356
export function checkUnsupportedFieldBindings(
319357
node: DirectiveOwner,

packages/compiler-cli/test/ngtsc/signal_forms_spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,5 +510,69 @@ runInEachFileSystem(() => {
510510
const diags = env.driveDiagnostics();
511511
expect(diags.length).toBe(0);
512512
});
513+
514+
it('should not check field on native control that has a ControlValueAccessor directive', () => {
515+
env.write(
516+
'test.ts',
517+
`
518+
import {Component, Directive, signal} from '@angular/core';
519+
import {ControlValueAccessor} from '@angular/forms';
520+
import {Field, form} from '@angular/forms/signals';
521+
522+
@Directive({selector: '[customCva]'})
523+
export class CustomCva implements ControlValueAccessor {
524+
writeValue() {}
525+
registerOnChange() {}
526+
registerOnTouched() {}
527+
}
528+
529+
@Component({
530+
template: '<input customCva [field]="f"/>',
531+
imports: [Field, CustomCva]
532+
})
533+
export class Comp {
534+
f = form(signal(0));
535+
}
536+
`,
537+
);
538+
539+
const diags = env.driveDiagnostics();
540+
expect(diags.length).toBe(0);
541+
});
542+
543+
it('should not check field on native control that has a directive inheriting from a ControlValueAccessor', () => {
544+
env.write(
545+
'test.ts',
546+
`
547+
import {Component, Directive, signal} from '@angular/core';
548+
import {ControlValueAccessor} from '@angular/forms';
549+
import {Field, form} from '@angular/forms/signals';
550+
551+
@Directive()
552+
export class Grandparent implements ControlValueAccessor {
553+
writeValue() {}
554+
registerOnChange() {}
555+
registerOnTouched() {}
556+
}
557+
558+
@Directive()
559+
export class Parent extends Grandparent {}
560+
561+
@Directive({selector: '[customCva]'})
562+
export class CustomCva extends Parent {}
563+
564+
@Component({
565+
template: '<input customCva [field]="f"/>',
566+
imports: [Field, CustomCva]
567+
})
568+
export class Comp {
569+
f = form(signal(0));
570+
}
571+
`,
572+
);
573+
574+
const diags = env.driveDiagnostics();
575+
expect(diags.length).toBe(0);
576+
});
513577
});
514578
});

0 commit comments

Comments
 (0)