From ca67828ee247bdff46736661e51f43f2ca736a24 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:39:42 -0500 Subject: [PATCH] refactor(compiler-cli): introduce NG8023 compile-time diagnostic for duplicate selectors Add NG8023 extended diagnostic to report duplicate component selectors during compilation. This replaces the former NG0300 runtime error, ensuring the failure occurs at build time instead of runtime. Closes angular#48377 BREAKING CHANGE: Elements with multiple matching selectors will now throw at compile time. --- .../public-api/compiler-cli/error_code.api.md | 1 + .../src/ngtsc/diagnostics/src/error_code.ts | 5 + .../src/ngtsc/typecheck/src/oob.ts | 33 ++++++ .../src/ngtsc/typecheck/src/ops/scope.ts | 11 ++ .../src/ngtsc/typecheck/testing/index.ts | 5 + .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 14 ++- .../test/ngtsc/template_typecheck_spec.ts | 110 ++++++++++++++++++ 7 files changed, 173 insertions(+), 6 deletions(-) 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); + }); + }); }); });