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.
This commit is contained in:
SkyZeroZx 2026-03-19 12:39:42 -05:00 committed by Leon Senft
parent eb53392b10
commit ca67828ee2
7 changed files with 173 additions and 6 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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(

View file

@ -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<TcbDirectiveMetadata, number>();
for (const dir of directives) {
this.appendDirectiveInputs(dir, node, dirMap, directives);

View file

@ -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,

View file

@ -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: \`
<div dir></div>
<comp></comp>
<comp-a></comp-a>
<comp-b></comp-b>
\`,
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: \`
<div dir></div>
<comp></comp>
<comp-a></comp-a>
<comp-b></comp-b>
\`,
standalone: false,
})

View file

@ -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: '<my-comp />',
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: '<button stroked-button raised-button></button>',
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: '<my-comp />',
imports: [CompA, DirB],
})
export class TestCmp {}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
});
});
});