Skip to content

Commit f97bebf

Browse files
crisbetodylhunn
authored andcommitted
fix(compiler-cli): implement more host directive validations as diagnostics (#47768)
Implements more of the runtime validations for host directives as compiler diagnostics so that they can be caught earlier. Also does some minor cleanup. PR Close #47768
1 parent cd25cb2 commit f97bebf

File tree

6 files changed

+279
-65
lines changed

6 files changed

+279
-65
lines changed

goldens/public-api/compiler-cli/error_code.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,10 @@ export enum ErrorCode {
3838
DUPLICATE_VARIABLE_DECLARATION = 8006,
3939
HOST_BINDING_PARSE_ERROR = 5001,
4040
HOST_DIRECTIVE_COMPONENT = 2015,
41+
HOST_DIRECTIVE_CONFLICTING_ALIAS = 2018,
4142
HOST_DIRECTIVE_INVALID = 2013,
4243
HOST_DIRECTIVE_NOT_STANDALONE = 2014,
44+
HOST_DIRECTIVE_UNDEFINED_BINDING = 2017,
4345
IMPORT_CYCLE_DETECTED = 3003,
4446
IMPORT_GENERATION_FAILURE = 3004,
4547
INJECTABLE_DUPLICATE_PROV = 9001,

packages/compiler-cli/src/ngtsc/annotations/common/src/diagnostics.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import ts from 'typescript';
1010

1111
import {ErrorCode, FatalDiagnosticError, makeDiagnostic, makeRelatedInformation} from '../../../diagnostics';
1212
import {Reference} from '../../../imports';
13-
import {HostDirectiveMeta, MetadataReader} from '../../../metadata';
13+
import {DirectiveMeta, flattenInheritedDirectiveMetadata, HostDirectiveMeta, MetadataReader} from '../../../metadata';
1414
import {describeResolvedType, DynamicValue, PartialEvaluator, ResolvedValue, traceDynamicValue} from '../../../partial_evaluator';
1515
import {ClassDeclaration, ReflectionHost} from '../../../reflection';
1616
import {DeclarationData, LocalModuleScopeRegistry} from '../../../scope';
@@ -161,7 +161,7 @@ export function validateHostDirectives(
161161
const diagnostics: ts.DiagnosticWithLocation[] = [];
162162

163163
for (const current of hostDirectives) {
164-
const hostMeta = metaReader.getDirectiveMetadata(current.directive);
164+
const hostMeta = flattenInheritedDirectiveMetadata(metaReader, current.directive);
165165

166166
if (hostMeta === null) {
167167
diagnostics.push(makeDiagnostic(
@@ -184,11 +184,52 @@ export function validateHostDirectives(
184184
ErrorCode.HOST_DIRECTIVE_COMPONENT, current.directive.getOriginForDiagnostics(origin),
185185
`Host directive ${hostMeta.name} cannot be a component`));
186186
}
187+
188+
validateHostDirectiveMappings('input', current, hostMeta, origin, diagnostics);
189+
validateHostDirectiveMappings('output', current, hostMeta, origin, diagnostics);
187190
}
188191

189192
return diagnostics;
190193
}
191194

195+
function validateHostDirectiveMappings(
196+
bindingType: 'input'|'output', hostDirectiveMeta: HostDirectiveMeta, meta: DirectiveMeta,
197+
origin: ts.Expression, diagnostics: ts.DiagnosticWithLocation[]) {
198+
const className = meta.name;
199+
const hostDirectiveMappings =
200+
bindingType === 'input' ? hostDirectiveMeta.inputs : hostDirectiveMeta.outputs;
201+
const existingBindings = bindingType === 'input' ? meta.inputs : meta.outputs;
202+
203+
for (const publicName in hostDirectiveMappings) {
204+
if (hostDirectiveMappings.hasOwnProperty(publicName)) {
205+
if (!existingBindings.hasBindingPropertyName(publicName)) {
206+
diagnostics.push(makeDiagnostic(
207+
ErrorCode.HOST_DIRECTIVE_UNDEFINED_BINDING,
208+
hostDirectiveMeta.directive.getOriginForDiagnostics(origin),
209+
`Directive ${className} does not have an ${bindingType} with a public name of ${
210+
publicName}.`));
211+
}
212+
213+
const remappedPublicName = hostDirectiveMappings[publicName];
214+
const bindingsForPublicName = existingBindings.getByBindingPropertyName(remappedPublicName);
215+
216+
if (bindingsForPublicName !== null) {
217+
for (const binding of bindingsForPublicName) {
218+
if (binding.bindingPropertyName !== publicName) {
219+
diagnostics.push(makeDiagnostic(
220+
ErrorCode.HOST_DIRECTIVE_CONFLICTING_ALIAS,
221+
hostDirectiveMeta.directive.getOriginForDiagnostics(origin),
222+
`Cannot alias ${bindingType} ${publicName} of host directive ${className} to ${
223+
remappedPublicName}, because it already has a different ${
224+
bindingType} with the same public name.`));
225+
}
226+
}
227+
}
228+
}
229+
}
230+
}
231+
232+
192233
export function getUndecoratedClassWithAngularFeaturesDiagnostic(node: ClassDeclaration):
193234
ts.Diagnostic {
194235
return makeDiagnostic(

packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,15 @@ export enum ErrorCode {
9292
*/
9393
INJECTABLE_INHERITS_INVALID_CONSTRUCTOR = 2016,
9494

95+
/** Raised when a host tries to alias a host directive binding that does not exist. */
96+
HOST_DIRECTIVE_UNDEFINED_BINDING = 2017,
97+
98+
/**
99+
* Raised when a host tries to alias a host directive
100+
* binding to a pre-existing binding's public name.
101+
*/
102+
HOST_DIRECTIVE_CONFLICTING_ALIAS = 2018,
103+
95104
SYMBOL_NOT_EXPORTED = 3001,
96105
/**
97106
* Raised when a relationship between directives and/or pipes would cause a cyclic import to be

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

Lines changed: 220 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ runInEachFileSystem(() => {
357357
import {ɵɵDirectiveDeclaration} from '@angular/core';
358358
359359
export declare class ExternalDir {
360-
static ɵdir: ɵɵDirectiveDeclaration<ExternalDir, '[test]', never, never,
361-
{input: "input"}, {output: "output"}, never, true, never>;
360+
static ɵdir: ɵɵDirectiveDeclaration<ExternalDir, '[test]', never,
361+
{input: "input"}, {output: "output"}, never, never, true, never>;
362362
}
363363
`);
364364

@@ -434,7 +434,7 @@ runInEachFileSystem(() => {
434434
diag.messageText.messageText;
435435
}
436436

437-
it('should throw if a host directive is not standalone', () => {
437+
it('should produce a diagnostic if a host directive is not standalone', () => {
438438
env.write('test.ts', `
439439
import {Directive, Component, NgModule} from '@angular/core';
440440
@@ -451,7 +451,7 @@ runInEachFileSystem(() => {
451451
expect(messages).toEqual(['Host directive HostDir must be standalone']);
452452
});
453453

454-
it('should throw if a host directive is a component', () => {
454+
it('should produce a diagnostic if a host directive is a component', () => {
455455
env.write('test.ts', `
456456
import {Directive, Component, NgModule} from '@angular/core';
457457
@@ -471,7 +471,7 @@ runInEachFileSystem(() => {
471471
expect(messages).toEqual(['Host directive HostComp cannot be a component']);
472472
});
473473

474-
it('should throw if hostDirectives is not an array', () => {
474+
it('should produce a diagnostic if hostDirectives is not an array', () => {
475475
env.write('test.ts', `
476476
import {Component} from '@angular/core';
477477
@@ -486,7 +486,7 @@ runInEachFileSystem(() => {
486486
expect(messages).toContain('hostDirectives must be an array');
487487
});
488488

489-
it('should throw if a host directive is not a reference', () => {
489+
it('should produce a diagnostic if a host directive is not a reference', () => {
490490
env.write('test.ts', `
491491
import {Component} from '@angular/core';
492492
@@ -503,7 +503,7 @@ runInEachFileSystem(() => {
503503
expect(messages).toEqual(['Host directive must be a reference']);
504504
});
505505

506-
it('should throw if a host directive is not a reference to a class', () => {
506+
it('should produce a diagnostic if a host directive is not a reference to a class', () => {
507507
env.write('test.ts', `
508508
import {Component} from '@angular/core';
509509
@@ -520,7 +520,7 @@ runInEachFileSystem(() => {
520520
expect(messages).toEqual(['Host directive reference must be a class']);
521521
});
522522

523-
it('should only throw host directive error once in a chain of directives', () => {
523+
it('should only produce a diagnostic once in a chain of directives', () => {
524524
env.write('test.ts', `
525525
import {Directive, Component, NgModule} from '@angular/core';
526526
@@ -544,12 +544,221 @@ runInEachFileSystem(() => {
544544
export class Host {}
545545
`);
546546

547-
// What we're checking here is that the errors aren't produced recursively. If that were
548-
// the case, the same error would show up more than once in the diagnostics since `HostDirB`
549-
// is in the chain of both `Host` and `HostDirA`.
547+
// What we're checking here is that the diagnostics aren't produced recursively. If that
548+
// were the case, the same diagnostic would show up more than once in the diagnostics since
549+
// `HostDirB` is in the chain of both `Host` and `HostDirA`.
550550
const messages = env.driveDiagnostics().map(extractMessage);
551551
expect(messages).toEqual(['Host directive HostDirB must be standalone']);
552552
});
553+
554+
it('should produce a diagnostic if a host directive output does not exist', () => {
555+
env.write('test.ts', `
556+
import {Directive, Output, EventEmitter} from '@angular/core';
557+
558+
@Directive({standalone: true})
559+
class HostDir {
560+
@Output() foo = new EventEmitter();
561+
}
562+
563+
@Directive({
564+
selector: '[dir]',
565+
hostDirectives: [{
566+
directive: HostDir,
567+
outputs: ['doesNotExist'],
568+
}]
569+
})
570+
class Dir {}
571+
`);
572+
573+
const messages = env.driveDiagnostics().map(extractMessage);
574+
expect(messages).toEqual(
575+
['Directive HostDir does not have an output with a public name of doesNotExist.']);
576+
});
577+
578+
it('should produce a diagnostic if a host directive output alias does not exist', () => {
579+
env.write('test.ts', `
580+
import {Directive, Output, EventEmitter} from '@angular/core';
581+
582+
@Directive({standalone: true})
583+
class HostDir {
584+
@Output('alias') foo = new EventEmitter();
585+
}
586+
587+
@Directive({
588+
selector: '[dir]',
589+
hostDirectives: [{
590+
directive: HostDir,
591+
outputs: ['foo'],
592+
}]
593+
})
594+
class Dir {}
595+
`);
596+
597+
const messages = env.driveDiagnostics().map(extractMessage);
598+
expect(messages).toEqual(
599+
['Directive HostDir does not have an output with a public name of foo.']);
600+
});
601+
602+
it('should produce a diagnostic if a host directive input does not exist', () => {
603+
env.write('test.ts', `
604+
import {Directive, Input} from '@angular/core';
605+
606+
@Directive({standalone: true})
607+
class HostDir {
608+
@Input() foo: any;
609+
}
610+
611+
@Directive({
612+
selector: '[dir]',
613+
hostDirectives: [{
614+
directive: HostDir,
615+
inputs: ['doesNotExist'],
616+
}]
617+
})
618+
class Dir {}
619+
`);
620+
621+
const messages = env.driveDiagnostics().map(extractMessage);
622+
expect(messages).toEqual(
623+
['Directive HostDir does not have an input with a public name of doesNotExist.']);
624+
});
625+
626+
it('should produce a diagnostic if a host directive input alias does not exist', () => {
627+
env.write('test.ts', `
628+
import {Directive, Input} from '@angular/core';
629+
630+
@Directive({standalone: true})
631+
class HostDir {
632+
@Input('alias') foo: any;
633+
}
634+
635+
@Directive({
636+
selector: '[dir]',
637+
hostDirectives: [{directive: HostDir, inputs: ['foo']}],
638+
})
639+
class Dir {}
640+
`);
641+
642+
const messages = env.driveDiagnostics().map(extractMessage);
643+
expect(messages).toEqual(
644+
['Directive HostDir does not have an input with a public name of foo.']);
645+
});
646+
647+
it('should produce a diagnostic if a host directive tries to alias to an existing input',
648+
() => {
649+
env.write('test.ts', `
650+
import {Directive, Input} from '@angular/core';
651+
652+
@Directive({selector: '[host-dir]', standalone: true})
653+
class HostDir {
654+
@Input('colorAlias') color?: string;
655+
@Input() buttonColor?: string;
656+
}
657+
658+
@Directive({
659+
selector: '[dir]',
660+
hostDirectives: [{directive: HostDir, inputs: ['colorAlias: buttonColor']}]
661+
})
662+
class Dir {}
663+
`);
664+
665+
const messages = env.driveDiagnostics().map(extractMessage);
666+
expect(messages).toEqual([
667+
'Cannot alias input colorAlias of host directive HostDir to buttonColor, because it ' +
668+
'already has a different input with the same public name.'
669+
]);
670+
});
671+
672+
it('should produce a diagnostic if a host directive tries to alias to an existing input alias',
673+
() => {
674+
env.write('test.ts', `
675+
import {Directive, Input} from '@angular/core';
676+
677+
@Directive({selector: '[host-dir]', standalone: true})
678+
class HostDir {
679+
@Input('colorAlias') color?: string;
680+
@Input('buttonColorAlias') buttonColor?: string;
681+
}
682+
683+
@Directive({
684+
selector: '[dir]',
685+
hostDirectives: [{directive: HostDir, inputs: ['colorAlias: buttonColorAlias']}]
686+
})
687+
class Dir {}
688+
`);
689+
690+
const messages = env.driveDiagnostics().map(extractMessage);
691+
expect(messages).toEqual(
692+
['Cannot alias input colorAlias of host directive HostDir to buttonColorAlias, ' +
693+
'because it already has a different input with the same public name.']);
694+
});
695+
696+
it('should not produce a diagnostic if a host directive input aliases to the same name',
697+
() => {
698+
env.write('test.ts', `
699+
import {Directive, Input} from '@angular/core';
700+
701+
@Directive({selector: '[host-dir]', standalone: true})
702+
class HostDir {
703+
@Input('color') color?: string;
704+
}
705+
706+
@Directive({
707+
selector: '[dir]',
708+
hostDirectives: [{directive: HostDir, inputs: ['color: buttonColor']}]
709+
})
710+
class Dir {}
711+
`);
712+
713+
const messages = env.driveDiagnostics().map(extractMessage);
714+
expect(messages).toEqual([]);
715+
});
716+
717+
it('should produce a diagnostic if a host directive tries to alias to an existing output alias',
718+
() => {
719+
env.write('test.ts', `
720+
import {Directive, Output, EventEmitter} from '@angular/core';
721+
722+
@Directive({selector: '[host-dir]', standalone: true})
723+
class HostDir {
724+
@Output('clickedAlias') clicked = new EventEmitter();
725+
@Output('tappedAlias') tapped = new EventEmitter();
726+
}
727+
728+
@Directive({
729+
selector: '[dir]',
730+
hostDirectives: [{directive: HostDir, outputs: ['clickedAlias: tappedAlias']}]
731+
})
732+
class Dir {}
733+
`);
734+
735+
const messages = env.driveDiagnostics().map(extractMessage);
736+
expect(messages).toEqual([
737+
'Cannot alias output clickedAlias of host directive HostDir ' +
738+
'to tappedAlias, because it already has a different output with the same public name.'
739+
]);
740+
});
741+
742+
it('should not produce a diagnostic if a host directive output aliases to the same name',
743+
() => {
744+
env.write('test.ts', `
745+
import {Directive, Output, EventEmitter} from '@angular/core';
746+
747+
@Directive({selector: '[host-dir]', standalone: true})
748+
class HostDir {
749+
@Output('clicked') clicked = new EventEmitter();
750+
}
751+
752+
@Directive({
753+
selector: '[dir]',
754+
hostDirectives: [{directive: HostDir, outputs: ['clicked: wasClicked']}]
755+
})
756+
class Dir {}
757+
`);
758+
759+
const messages = env.driveDiagnostics().map(extractMessage);
760+
expect(messages).toEqual([]);
761+
});
553762
});
554763
});
555764
});

0 commit comments

Comments
 (0)