diff --git a/goldens/public-api/compiler-cli/error_code.api.md b/goldens/public-api/compiler-cli/error_code.api.md index a02e8c70045..484d1a74135 100644 --- a/goldens/public-api/compiler-cli/error_code.api.md +++ b/goldens/public-api/compiler-cli/error_code.api.md @@ -89,6 +89,7 @@ export enum ErrorCode { MISSING_REFERENCE_TARGET = 8003, MISSING_REQUIRED_INPUTS = 8008, MISSING_STRUCTURAL_DIRECTIVE = 8116, + MULTIPLE_MATCHING_COMPONENTS = 8023, NGMODULE_BOOTSTRAP_IS_STANDALONE = 6009, NGMODULE_DECLARATION_IS_STANDALONE = 6008, NGMODULE_DECLARATION_NOT_UNIQUE = 6007, diff --git a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts index 5fe8ebcc436..b9b3c298753 100644 --- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts +++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts @@ -444,6 +444,11 @@ export enum ErrorCode { /** Raised when the user has an unsupported binding on a `FormField` directive. */ FORM_FIELD_UNSUPPORTED_BINDING = 8022, + /** + * Raised when multiple components in the compilation scope match a given element in a template. + */ + MULTIPLE_MATCHING_COMPONENTS = 8023, + /** * A two way binding in a template has an incorrect syntax, * parentheses outside brackets. For example: diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts index b441577818f..4d2d0bb3f4c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts @@ -247,6 +247,15 @@ export interface OutOfBandDiagnosticRecorder { id: TypeCheckId, node: TmplAstBoundAttribute | TmplAstTextAttribute, ): void; + + /** + * Reports that multiple components in the compilation scope match a given element. + */ + multipleMatchingComponents( + id: TypeCheckId, + element: TmplAstElement, + componentNames: string[], + ): void; } export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecorder { @@ -909,6 +918,30 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor ), ); } + + multipleMatchingComponents( + id: TypeCheckId, + element: TmplAstElement, + componentNames: string[], + ): void { + const start = element.startSourceSpan.start.moveBy(1); + const end = element.startSourceSpan.end.moveBy( + start.offset + element.name.length - element.startSourceSpan.end.offset, + ); + const span = new ParseSourceSpan(start, end); + const names = componentNames.map((n: string) => `'${n}'`).join(', '); + + this._diagnostics.push( + makeTemplateDiagnostic( + id, + this.resolver.getTemplateSourceMapping(id), + span, + ts.DiagnosticCategory.Error, + ngErrorCode(ErrorCode.MULTIPLE_MATCHING_COMPONENTS), + `Multiple components match node with tagname ${element.name}: ${names}.`, + ), + ); + } } function makeInlineDiagnostic( diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/scope.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/scope.ts index 33b8f19d540..b38343d986c 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/scope.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/scope.ts @@ -594,6 +594,17 @@ export class Scope { } } + if (node instanceof TmplAstElement) { + const matchedComponents = directives.filter((dir) => dir.isComponent); + if (matchedComponents.length > 1) { + this.tcb.oobRecorder.multipleMatchingComponents( + this.tcb.id, + node, + matchedComponents.map((dir) => dir.name), + ); + } + } + const dirMap = new Map(); for (const dir of directives) { this.appendDirectiveInputs(dir, node, dirMap, directives); diff --git a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts index 4cb10ee4c51..2948bdeec58 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/testing/index.ts @@ -1062,6 +1062,11 @@ export class NoopOobRecorder implements OutOfBandDiagnosticRecorder { target: TmplAstLetDeclaration, ): void {} conflictingDeclaration(id: TypeCheckId, current: TmplAstLetDeclaration): void {} + multipleMatchingComponents( + id: TypeCheckId, + element: TmplAstElement, + componentNames: string[], + ): void {} missingNamedTemplateDependency( id: TypeCheckId, node: TmplAstComponent | TmplAstDirective, diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 785d22187e8..ff68ef32fe9 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -1450,7 +1450,7 @@ runInEachFileSystem((os: string) => { class DirectiveA {} @Component({ - selector: 'comp', + selector: 'comp-a', template: '...', standalone: false, }) @@ -1471,7 +1471,7 @@ runInEachFileSystem((os: string) => { class DirectiveB {} @Component({ - selector: 'comp', + selector: 'comp-b', template: '...', standalone: false, }) @@ -1481,7 +1481,8 @@ runInEachFileSystem((os: string) => { selector: 'app', template: \`
- + + \`, standalone: false, }) @@ -1556,7 +1557,7 @@ runInEachFileSystem((os: string) => { class DirectiveA {} @Component({ - selector: 'comp', + selector: 'comp-a', template: '...', standalone: false, }) @@ -1577,7 +1578,7 @@ runInEachFileSystem((os: string) => { class DirectiveB {} @Component({ - selector: 'comp', + selector: 'comp-b', template: '...', standalone: false, }) @@ -1595,7 +1596,8 @@ runInEachFileSystem((os: string) => { selector: 'app', template: \`
- + + \`, standalone: false, }) diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index f1982b876c0..1a2fc2cb4fb 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -8791,5 +8791,115 @@ suppress expect(diags.length).toBe(0); }); }); + + describe('multiple matching components', () => { + it('should report an error when multiple components match the same element', () => { + env.tsconfig({strictTemplates: true}); + env.write( + 'test.ts', + ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'my-comp', + template: '', + standalone: false, + }) + export class CompA {} + + @Component({ + selector: 'my-comp', + template: '', + standalone: false, + }) + export class CompB {} + + @Component({ + selector: 'test', + template: '', + standalone: false, + }) + export class TestCmp {} + + @NgModule({ + declarations: [TestCmp, CompA, CompB], + }) + export class Module {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.MULTIPLE_MATCHING_COMPONENTS)); + expect(diags[0].messageText).toContain( + 'Multiple components match node with tagname my-comp', + ); + }); + + it('should report an error when multiple components with attribute selectors match the same element', () => { + env.tsconfig({strictTemplates: true}); + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + + + @Component({ + selector: '[stroked-button]', + template: '', + }) + export class StrokedBtn {} + + @Component({ + selector: '[raised-button]', + template: '', + }) + export class RaisedBtn {} + + @Component({ + selector: 'app-root', + template: '', + imports: [StrokedBtn, RaisedBtn], + }) + export class App {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].code).toBe(ngErrorCode(ErrorCode.MULTIPLE_MATCHING_COMPONENTS)); + expect(diags[0].messageText).toContain( + 'Multiple components match node with tagname button', + ); + }); + + it('should not report an error when a single component and directives match', () => { + env.tsconfig({strictTemplates: true}); + env.write( + 'test.ts', + ` + import {Component, Directive} from '@angular/core'; + + @Component({ + selector: 'my-comp', + template: '', + }) + export class CompA {} + + @Directive({ + selector: 'my-comp', + }) + export class DirB {} + + @Component({ + selector: 'test', + template: '', + imports: [CompA, DirB], + }) + export class TestCmp {} + `, + ); + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + }); }); });