fix(compiler): throw on duplicate input/outputs

inputs & outputs cannot be binded to 2 different directives/components properties

Eg
```
data = model();
dataChange = output(); // throws because model already emits on the `dataChange` output

userSomething = input({alias 'user'});
user = input(); // throws because userSomething already binds to the `user` input
````

fixes #65844

BREAKING CHANGE: The compiler will throw when there a when inputs, outputs or model are binding to the same input/outputs.
This commit is contained in:
Matthieu Riegler
2025-12-10 02:03:59 +01:00
committed by Jessica Janiuk
parent 3bc095d508
commit 03db2aefaa
8 changed files with 42 additions and 11 deletions
@@ -49,6 +49,7 @@ export enum ErrorCode {
DIRECTIVE_INHERITS_UNDECORATED_CTOR = 2006,
// (undocumented)
DIRECTIVE_MISSING_SELECTOR = 2004,
DUPLICATE_BINDING_NAME = 1054,
// (undocumented)
DUPLICATE_DECORATED_PROPERTIES = 1012,
DUPLICATE_VARIABLE_DECLARATION = 8006,
@@ -1346,6 +1346,7 @@ function parseInputFields(
emitDeclarationOnly: boolean,
): Record<string, InputMapping> {
const inputs = {} as Record<string, InputMapping>;
const bindings = new Map<string, ClassMember>();
for (const member of members) {
const classPropertyName = member.name;
@@ -1364,6 +1365,18 @@ function parseInputFields(
continue;
}
const bindingPropertyName = inputMapping.bindingPropertyName;
if (bindings.has(bindingPropertyName)) {
const firstMember = bindings.get(bindingPropertyName)!;
throw new FatalDiagnosticError(
ErrorCode.DUPLICATE_BINDING_NAME,
member.node ?? clazz,
`Input '${bindingPropertyName}' is bound to both '${firstMember.name}' and '${member.name}'.`,
[makeRelatedInformation(firstMember.node ?? clazz, `The first binding is declared here.`)],
);
}
bindings.set(bindingPropertyName, member);
if (member.isStatic) {
throw new FatalDiagnosticError(
ErrorCode.INCORRECTLY_DECLARED_ON_STATIC_MEMBER,
@@ -1742,6 +1755,7 @@ function parseOutputFields(
outputsFromMeta: Record<string, string>,
): Record<string, string> {
const outputs = {} as Record<string, string>;
const bindings = new Map<string, ClassMember>();
for (const member of members) {
const decoratorOutput = tryParseDecoratorOutput(member, evaluator, isCore);
@@ -1786,6 +1800,17 @@ function parseOutputFields(
continue;
}
if (bindings.has(bindingPropertyName)) {
const firstMember = bindings.get(bindingPropertyName)!;
throw new FatalDiagnosticError(
ErrorCode.DUPLICATE_BINDING_NAME,
member.node ?? clazz,
`Output '${bindingPropertyName}' is bound to both '${firstMember.name}' and '${member.name}'.`,
[makeRelatedInformation(firstMember.node ?? clazz, `The first binding is declared here.`)],
);
}
bindings.set(bindingPropertyName, member);
// Validate that initializer-based outputs are not accidentally declared
// in the `outputs` class metadata.
if (
@@ -52,6 +52,11 @@ export enum ErrorCode {
*/
INITIALIZER_API_DISALLOWED_MEMBER_VISIBILITY = 1053,
/**
* Raised whenever there are duplicate binding property names for outputs, inputs & models.
*/
DUPLICATE_BINDING_NAME = 1054,
/**
* An Angular feature, like inputs, outputs or queries is incorrectly
* declared on a static member.
@@ -84,7 +84,7 @@ export class TestDir {
clickDecorator2 = new EventEmitter();
_blaDecorator = new EventEmitter();
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestDir, deps: [], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestDir, isStandalone: true, outputs: { click1: "click1", click2: "click2", click3: "click3", _bla: "decoratorPublicName", _bla2: "decoratorPublicName2", clickDecorator1: "clickDecorator1", clickDecorator2: "clickDecorator2", _blaDecorator: "decoratorPublicName" }, ngImport: i0 });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestDir, isStandalone: true, outputs: { click1: "click1", click2: "click2", click3: "click3", _bla: "decoratorPublicName", _bla2: "decoratorPublicName2", clickDecorator1: "clickDecorator1", clickDecorator2: "clickDecorator2", _blaDecorator: "decoratorPublicName3" }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestDir, decorators: [{
type: Directive
@@ -94,7 +94,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
type: Output
}], _blaDecorator: [{
type: Output,
args: ['decoratorPublicName']
args: ['decoratorPublicName3']
}] } });
/****************************************************************************************************
@@ -112,6 +112,6 @@ export declare class TestDir {
clickDecorator2: EventEmitter<boolean>;
_blaDecorator: EventEmitter<void>;
static ɵfac: i0.ɵɵFactoryDeclaration<TestDir, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, {}, { "click1": "click1"; "click2": "click2"; "click3": "click3"; "_bla": "decoratorPublicName"; "_bla2": "decoratorPublicName2"; "clickDecorator1": "clickDecorator1"; "clickDecorator2": "clickDecorator2"; "_blaDecorator": "decoratorPublicName"; }, never, never, true, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<TestDir, never, never, {}, { "click1": "click1"; "click2": "click2"; "click3": "click3"; "_bla": "decoratorPublicName"; "_bla2": "decoratorPublicName2"; "clickDecorator1": "clickDecorator1"; "clickDecorator2": "clickDecorator2"; "_blaDecorator": "decoratorPublicName3"; }, never, never, true, never>;
}
@@ -10,7 +10,7 @@ export class TestDir {
_bla2: "decoratorPublicName2",
clickDecorator1: "clickDecorator1",
clickDecorator2: "clickDecorator2",
_blaDecorator: "decoratorPublicName"
_blaDecorator: "decoratorPublicName3"
}
…
});
@@ -11,5 +11,5 @@ export class TestDir {
@Output() clickDecorator1 = new EventEmitter();
@Output() clickDecorator2 = new EventEmitter<boolean>();
@Output('decoratorPublicName') _blaDecorator = new EventEmitter<void>();
@Output('decoratorPublicName3') _blaDecorator = new EventEmitter<void>();
}
@@ -155,7 +155,8 @@ runInEachFileSystem(() => {
expect(diags[0].code).toBeGreaterThan(0);
});
it('should produce diagnostics when mapping to multiple fields and bound types are incorrect', () => {
// This is not supported at runtime
xit('should produce diagnostics when mapping to multiple fields and bound types are incorrect', () => {
env.tsconfig({
fullTemplateTypeCheck: true,
strictInputTypes: true,
@@ -241,7 +242,8 @@ runInEachFileSystem(() => {
);
});
it('should support one input property mapping to multiple fields', () => {
/** This is not supported at runtime */
xit('should support one input property mapping to multiple fields', () => {
env.write(
'test.ts',
`
@@ -59,7 +59,7 @@ describe('getSemanticDiagnostics', () => {
expect(messageText).toBe(`Property 'nope' does not exist on type 'AppComponent'.`);
});
it('produces diagnostic for duplicate docarated property rather than crashing', () => {
it('produces diagnostic for duplicate decorated property rather than crashing', () => {
const files = {
'app.ts': `
import {Component, Input} from '@angular/core';
@@ -82,9 +82,7 @@ describe('getSemanticDiagnostics', () => {
expect(diags[0].messageText).toBe(`Duplicate identifier 'test1'.`);
expect(diags[1].category).toBe(ts.DiagnosticCategory.Error);
expect(diags[1].file?.fileName).toBe('/test/app.ts');
expect(diags[1].messageText).toBe(
`Duplicate decorated properties found on class 'AppComponent': test1`,
);
expect(diags[1].messageText).toBe(`Input 'test1' is bound to both 'test1' and 'test1'.`);
});
it('should process external template', () => {