From 81dce5c6649ddc56903a37ef6a26dcd3ee37df96 Mon Sep 17 00:00:00 2001
From: Daniel Trevino <23410540+danieltre23@users.noreply.github.com>
Date: Tue, 22 Jun 2021 10:48:27 -0500
Subject: [PATCH] fix(compiler-cli): check split two way binding (#42601)
Check for split two way binding when output is not declared to make error message clearer.
PR Close #42601
---
goldens/public-api/compiler-cli/error_code.md | 1 +
.../src/ngtsc/diagnostics/src/error_code.ts | 6 ++
.../typecheck/diagnostics/src/diagnostic.ts | 49 +++++++-----
.../src/ngtsc/typecheck/src/oob.ts | 71 +++++++++++++++--
.../ngtsc/typecheck/src/type_check_block.ts | 38 +++++++++
.../src/ngtsc/typecheck/test/test_utils.ts | 1 +
.../test/ngtsc/template_typecheck_spec.ts | 79 +++++++++++++++++++
7 files changed, 217 insertions(+), 28 deletions(-)
diff --git a/goldens/public-api/compiler-cli/error_code.md b/goldens/public-api/compiler-cli/error_code.md
index cdccfd018d..84a4a59516 100644
--- a/goldens/public-api/compiler-cli/error_code.md
+++ b/goldens/public-api/compiler-cli/error_code.md
@@ -50,6 +50,7 @@ export enum ErrorCode {
PIPE_MISSING_NAME = 2002,
SCHEMA_INVALID_ATTRIBUTE = 8002,
SCHEMA_INVALID_ELEMENT = 8001,
+ SPLIT_TWO_WAY_BINDING = 8007,
SUGGEST_STRICT_TEMPLATES = 10001,
SUGGEST_SUBOPTIMAL_TYPE_INFERENCE = 10002,
// (undocumented)
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 a0dc5e3f5a..e6dba314c2 100644
--- a/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts
+++ b/packages/compiler-cli/src/ngtsc/diagnostics/src/error_code.ts
@@ -166,6 +166,12 @@ export enum ErrorCode {
*/
DUPLICATE_VARIABLE_DECLARATION = 8006,
+ /**
+ * A template has a two way binding (two bindings created by a single syntactial element)
+ * in which the input and output are going to different places.
+ */
+ SPLIT_TWO_WAY_BINDING = 8007,
+
/**
* The template type-checking engine would need to generate an inline type check block for a
* component, but the current type-checking environment doesn't support it.
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/diagnostics/src/diagnostic.ts b/packages/compiler-cli/src/ngtsc/typecheck/diagnostics/src/diagnostic.ts
index 4070a47a7a..61d2f143aa 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/diagnostics/src/diagnostic.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/diagnostics/src/diagnostic.ts
@@ -33,21 +33,26 @@ export interface TemplateDiagnostic extends ts.Diagnostic {
export function makeTemplateDiagnostic(
templateId: TemplateId, mapping: TemplateSourceMapping, span: ParseSourceSpan,
category: ts.DiagnosticCategory, code: number, messageText: string|ts.DiagnosticMessageChain,
- relatedMessage?: {
+ relatedMessages?: {
text: string,
- span: ParseSourceSpan,
- }): TemplateDiagnostic {
+ start: number,
+ end: number,
+ sourceFile: ts.SourceFile,
+ }[]): TemplateDiagnostic {
if (mapping.type === 'direct') {
let relatedInformation: ts.DiagnosticRelatedInformation[]|undefined = undefined;
- if (relatedMessage !== undefined) {
- relatedInformation = [{
- category: ts.DiagnosticCategory.Message,
- code: 0,
- file: mapping.node.getSourceFile(),
- start: relatedMessage.span.start.offset,
- length: relatedMessage.span.end.offset - relatedMessage.span.start.offset,
- messageText: relatedMessage.text,
- }];
+ if (relatedMessages !== undefined) {
+ relatedInformation = [];
+ for (const relatedMessage of relatedMessages) {
+ relatedInformation.push({
+ category: ts.DiagnosticCategory.Message,
+ code: 0,
+ file: relatedMessage.sourceFile,
+ start: relatedMessage.start,
+ length: relatedMessage.end - relatedMessage.start,
+ messageText: relatedMessage.text,
+ });
+ }
}
// For direct mappings, the error is shown inline as ngtsc was able to pinpoint a string
// constant within the `@Component` decorator for the template. This allows us to map the error
@@ -82,15 +87,17 @@ export function makeTemplateDiagnostic(
fileName, mapping.template, ts.ScriptTarget.Latest, false, ts.ScriptKind.JSX);
let relatedInformation: ts.DiagnosticRelatedInformation[] = [];
- if (relatedMessage !== undefined) {
- relatedInformation.push({
- category: ts.DiagnosticCategory.Message,
- code: 0,
- file: sf,
- start: relatedMessage.span.start.offset,
- length: relatedMessage.span.end.offset - relatedMessage.span.start.offset,
- messageText: relatedMessage.text,
- });
+ if (relatedMessages !== undefined) {
+ for (const relatedMessage of relatedMessages) {
+ relatedInformation.push({
+ category: ts.DiagnosticCategory.Message,
+ code: 0,
+ file: relatedMessage.sourceFile,
+ start: relatedMessage.start,
+ length: relatedMessage.end - relatedMessage.start,
+ messageText: relatedMessage.text,
+ });
+ }
}
relatedInformation.push({
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts
index 446debb4ee..ec841fcd69 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/oob.ts
@@ -6,7 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {BindingPipe, PropertyWrite, TmplAstReference, TmplAstVariable} from '@angular/compiler';
+import {BindingPipe, PropertyWrite, TmplAstBoundEvent, TmplAstElement, TmplAstReference, TmplAstVariable} from '@angular/compiler';
+import {BoundAttribute} from '@angular/compiler/src/render3/r3_ast';
import * as ts from 'typescript';
import {ErrorCode, makeDiagnostic, makeRelatedInformation, ngErrorCode} from '../../diagnostics';
@@ -74,6 +75,13 @@ export interface OutOfBandDiagnosticRecorder {
* type-checking configuration prohibits their usage.
*/
suboptimalTypeInference(templateId: TemplateId, variables: TmplAstVariable[]): void;
+
+ /**
+ * Reports a split two way binding error message.
+ */
+ splitTwoWayBinding(
+ templateId: TemplateId, input: BoundAttribute, output: TmplAstBoundEvent,
+ inputConsumer: ClassDeclaration, outputConsumer: ClassDeclaration|TmplAstElement): void;
}
export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecorder {
@@ -133,10 +141,12 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor
}
this._diagnostics.push(makeTemplateDiagnostic(
templateId, mapping, sourceSpan, ts.DiagnosticCategory.Error,
- ngErrorCode(ErrorCode.WRITE_TO_READ_ONLY_VARIABLE), errorMsg, {
+ ngErrorCode(ErrorCode.WRITE_TO_READ_ONLY_VARIABLE), errorMsg, [{
text: `The variable ${assignment.name} is declared here.`,
- span: target.valueSpan || target.sourceSpan,
- }));
+ start: target.valueSpan?.start.offset || target.sourceSpan.start.offset,
+ end: target.valueSpan?.end.offset || target.sourceSpan.end.offset,
+ sourceFile: mapping.node.getSourceFile(),
+ }]));
}
duplicateTemplateVar(
@@ -152,10 +162,12 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor
// TODO(alxhub): allocate to a tighter span once one is available.
this._diagnostics.push(makeTemplateDiagnostic(
templateId, mapping, variable.sourceSpan, ts.DiagnosticCategory.Error,
- ngErrorCode(ErrorCode.DUPLICATE_VARIABLE_DECLARATION), errorMsg, {
+ ngErrorCode(ErrorCode.DUPLICATE_VARIABLE_DECLARATION), errorMsg, [{
text: `The variable '${firstDecl.name}' was first declared here.`,
- span: firstDecl.sourceSpan,
- }));
+ start: firstDecl.sourceSpan.start.offset,
+ end: firstDecl.sourceSpan.end.offset,
+ sourceFile: mapping.node.getSourceFile(),
+ }]));
}
requiresInlineTcb(templateId: TemplateId, node: ClassDeclaration): void {
@@ -211,6 +223,51 @@ export class OutOfBandDiagnosticRecorderImpl implements OutOfBandDiagnosticRecor
templateId, mapping, diagnosticVar.keySpan, ts.DiagnosticCategory.Suggestion,
ngErrorCode(ErrorCode.SUGGEST_SUBOPTIMAL_TYPE_INFERENCE), message));
}
+
+ splitTwoWayBinding(
+ templateId: TemplateId, input: BoundAttribute, output: TmplAstBoundEvent,
+ inputConsumer: ClassDeclaration, outputConsumer: ClassDeclaration|TmplAstElement): void {
+ const mapping = this.resolver.getSourceMapping(templateId);
+ const errorMsg = `The property and event halves of the two-way binding '${
+ input.name}' are not bound to the same target.
+ Find more at https://angular.io/guide/two-way-binding#how-two-way-binding-works`;
+
+ const relatedMessages: {text: string; start: number; end: number;
+ sourceFile: ts.SourceFile;}[] = [];
+
+ relatedMessages.push({
+ text: `The property half of the binding is to the '${inputConsumer.name.text}' component.`,
+ start: inputConsumer.name.getStart(),
+ end: inputConsumer.name.getEnd(),
+ sourceFile: inputConsumer.name.getSourceFile(),
+ });
+
+ if (outputConsumer instanceof TmplAstElement) {
+ let message = `The event half of the binding is to a native event called '${
+ input.name}' on the <${outputConsumer.name}> DOM element.`;
+ if (!mapping.node.getSourceFile().isDeclarationFile) {
+ message += `\n \n Are you missing an output declaration called '${output.name}'?`;
+ }
+ relatedMessages.push({
+ text: message,
+ start: outputConsumer.sourceSpan.start.offset + 1,
+ end: outputConsumer.sourceSpan.start.offset + outputConsumer.name.length + 1,
+ sourceFile: mapping.node.getSourceFile(),
+ });
+ } else {
+ relatedMessages.push({
+ text: `The event half of the binding is to the '${outputConsumer.name.text}' component.`,
+ start: outputConsumer.name.getStart(),
+ end: outputConsumer.name.getEnd(),
+ sourceFile: outputConsumer.name.getSourceFile(),
+ });
+ }
+
+
+ this._diagnostics.push(makeTemplateDiagnostic(
+ templateId, mapping, input.keySpan, ts.DiagnosticCategory.Error,
+ ngErrorCode(ErrorCode.SPLIT_TWO_WAY_BINDING), errorMsg, relatedMessages));
+ }
}
function makeInlineDiagnostic(
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
index f905a4915d..566f772734 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
@@ -984,6 +984,11 @@ export class TcbDirectiveOutputsOp extends TcbOp {
if (output.type !== ParsedEventType.Regular || !outputs.hasBindingPropertyName(output.name)) {
continue;
}
+
+ if (this.tcb.env.config.checkTypeOfOutputEvents && output.name.endsWith('Change')) {
+ const inputName = output.name.slice(0, -6);
+ isSplitTwoWayBinding(inputName, output, this.node.inputs, this.tcb);
+ }
// TODO(alxhub): consider supporting multiple fields with the same property name for outputs.
const field = outputs.getByBindingPropertyName(output.name)![0].classPropertyName;
@@ -1049,6 +1054,14 @@ class TcbUnclaimedOutputsOp extends TcbOp {
continue;
}
+ if (this.tcb.env.config.checkTypeOfOutputEvents && output.name.endsWith('Change')) {
+ const inputName = output.name.slice(0, -6);
+ if (isSplitTwoWayBinding(inputName, output, this.element.inputs, this.tcb)) {
+ // Skip this event handler as the error was already handled.
+ continue;
+ }
+ }
+
if (output.type === ParsedEventType.Animation) {
// Animation output bindings always have an `$event` parameter of type `AnimationEvent`.
const eventType = this.tcb.env.config.checkTypeOfAnimationEvents ?
@@ -1979,6 +1992,31 @@ function tcbEventHandlerExpression(ast: AST, tcb: Context, scope: Scope): ts.Exp
return translator.translate(ast);
}
+function isSplitTwoWayBinding(
+ inputName: string, output: TmplAstBoundEvent, inputs: TmplAstBoundAttribute[], tcb: Context) {
+ const input = inputs.find(input => input.name === inputName);
+ if (input === undefined || input.sourceSpan !== output.sourceSpan) {
+ return false;
+ }
+ // Input consumer should be a directive because it's claimed
+ const inputConsumer = tcb.boundTarget.getConsumerOfBinding(input) as TypeCheckableDirectiveMeta;
+ const outputConsumer = tcb.boundTarget.getConsumerOfBinding(output);
+ if (outputConsumer === null || inputConsumer.ref === undefined ||
+ outputConsumer instanceof TmplAstTemplate) {
+ return false;
+ }
+ if (outputConsumer instanceof TmplAstElement) {
+ tcb.oobRecorder.splitTwoWayBinding(
+ tcb.id, input, output, inputConsumer.ref.node, outputConsumer);
+ return true;
+ } else if (outputConsumer.ref !== inputConsumer.ref) {
+ tcb.oobRecorder.splitTwoWayBinding(
+ tcb.id, input, output, inputConsumer.ref.node, outputConsumer.ref.node);
+ return true;
+ }
+ return false;
+}
+
class TcbEventHandlerTranslator extends TcbExpressionTranslator {
protected override resolve(ast: AST): ts.Expression|null {
// Recognize a property read on the implicit receiver corresponding with the event parameter
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
index 9130af7095..860b0aafad 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
@@ -694,4 +694,5 @@ export class NoopOobRecorder implements OutOfBandDiagnosticRecorder {
requiresInlineTcb(): void {}
requiresInlineTypeConstructors(): void {}
suboptimalTypeInference(): void {}
+ splitTwoWayBinding(): void {}
}
diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
index 3d28fb5841..9543acfcc9 100644
--- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
+++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
@@ -338,6 +338,85 @@ export declare class AnimationEvent {
expect(diags.length).toBe(0);
});
+ it('should check split two way binding', () => {
+ env.tsconfig({strictTemplates: true});
+ env.write('test.ts', `
+ import {Component, Input, NgModule} from '@angular/core';
+
+ @Component({
+ selector: 'test',
+ template: '',
+ })
+
+ export class TestCmp {
+ counterValue = 0;
+ }
+
+ @Component({
+ selector: 'child-cmp',
+ template: '',
+ })
+
+ export class ChildCmp {
+ @Input() value = 0;
+ }
+
+ @NgModule({
+ declarations: [TestCmp, ChildCmp],
+ })
+ export class Module {}
+ `);
+ const diags = env.driveDiagnostics();
+ expect(diags.length).toBe(1);
+ expect(diags[0].code).toBe(ngErrorCode(ErrorCode.SPLIT_TWO_WAY_BINDING));
+ expect(getSourceCodeForDiagnostic(diags[0])).toBe('value');
+ expect(diags[0].relatedInformation!.length).toBe(2);
+ expect(getSourceCodeForDiagnostic(diags[0].relatedInformation![0])).toBe('ChildCmp');
+ expect(getSourceCodeForDiagnostic(diags[0].relatedInformation![1])).toBe('child-cmp');
+ });
+
+ it('when input and output go to different directives', () => {
+ env.tsconfig({strictTemplates: true});
+ env.write('test.ts', `
+ import {Component, Input, NgModule, Output, Directive} from '@angular/core';
+
+ @Component({
+ selector: 'test',
+ template: '',
+ })
+ export class TestCmp {
+ counterValue = 0;
+ }
+
+ @Directive({
+ selector: 'child-cmp'
+ })
+ export class ChildCmpDir {
+ @Output() valueChange: any;
+ }
+
+ @Component({
+ selector: 'child-cmp',
+ template: '',
+ })
+ export class ChildCmp {
+ @Input() value = 0;
+ }
+
+ @NgModule({
+ declarations: [TestCmp, ChildCmp, ChildCmpDir],
+ })
+ export class Module {}
+ `);
+ const diags = env.driveDiagnostics();
+ expect(diags.length).toBe(1);
+ expect(diags[0].code).toBe(ngErrorCode(ErrorCode.SPLIT_TWO_WAY_BINDING));
+ expect(getSourceCodeForDiagnostic(diags[0])).toBe('value');
+ expect(diags[0].relatedInformation!.length).toBe(2);
+ expect(getSourceCodeForDiagnostic(diags[0].relatedInformation![0])).toBe('ChildCmp');
+ expect(getSourceCodeForDiagnostic(diags[0].relatedInformation![1])).toBe('ChildCmpDir');
+ });
+
describe('strictInputTypes', () => {
beforeEach(() => {
env.write('test.ts', `