Skip to content

Commit

Permalink
fix(ivy): avoid duplicate errors in safe navigations and template gua…
Browse files Browse the repository at this point in the history
…rds (#34417)

The template type checker generates TypeScript expressions for any
expression that occurs in a template, so that TypeScript can check it
and produce errors. Some expressions as they occur in a template may be
translated into TypeScript code multiple times, for instance a binding
to a directive input that has a template guard.

One example would be the `NgIf` directive, which has a template guard to
narrow the type in the template as appropriate. Given the following
template:

```typescript
@component({
  template: '<div *ngIf="person">{{ person.name }}</div>'
})
class AppComponent {
  person?: { name: string };
}
```

A type check block (TCB) with roughly the following structure is
created:

```typescript
function tcb(ctx: AppComponent) {
  const t1 = NgIf.ngTypeCtor({ ngIf: ctx.person });
  if (ctx.person) {
    "" + ctx.person.name;
  }
}
```

Notice how the `*ngIf="person"` binding is present twice: once in the
type constructor call and once in the `if` guard. As such, TypeScript
will check both instances and would produce duplicate errors, if any
were found.

Another instance is when the safe navigation operator is used, where an
expression such as `person?.name` is emitted into the TCB as
`person != null ? person!.name : undefined`. As can be seen, the
left-hand side expression `person` occurs twice in the TCB.

This commit adds the ability to insert markers into the TCB that
indicate that any errors within the expression should be ignored. This
is similar to `@ts-ignore`, however it can be applied more granularly.

PR Close #34417
  • Loading branch information
JoostK authored and kara committed Dec 18, 2019
1 parent 8229764 commit d6edeab
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 29 deletions.
73 changes: 53 additions & 20 deletions packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts
Expand Up @@ -46,6 +46,16 @@ export function wrapForDiagnostics(expr: ts.Expression): ts.Expression {
return ts.createParen(expr); return ts.createParen(expr);
} }


const IGNORE_MARKER = 'ignore';

/**
* Adds a marker to the node that signifies that any errors within the node should not be reported.
*/
export function ignoreDiagnostics(node: ts.Node): void {
ts.addSyntheticTrailingComment(
node, ts.SyntaxKind.MultiLineCommentTrivia, IGNORE_MARKER, /* hasTrailingNewLine */ false);
}

/** /**
* Adds a synthetic comment to the expression that represents the parse span of the provided node. * Adds a synthetic comment to the expression that represents the parse span of the provided node.
* This comment can later be retrieved as trivia of a node to recover original source locations. * This comment can later be retrieved as trivia of a node to recover original source locations.
Expand Down Expand Up @@ -214,17 +224,20 @@ interface SourceLocation {
span: AbsoluteSourceSpan; span: AbsoluteSourceSpan;
} }


/**
* Traverses up the AST starting from the given node to extract the source location from comments
* that have been emitted into the TCB. If the node does not exist within a TCB, or if an ignore
* marker comment is found up the tree, this function returns null.
*/
function findSourceLocation(node: ts.Node, sourceFile: ts.SourceFile): SourceLocation|null { function findSourceLocation(node: ts.Node, sourceFile: ts.SourceFile): SourceLocation|null {
// Search for comments until the TCB's function declaration is encountered. // Search for comments until the TCB's function declaration is encountered.
while (node !== undefined && !ts.isFunctionDeclaration(node)) { while (node !== undefined && !ts.isFunctionDeclaration(node)) {
const span = if (hasIgnoreMarker(node, sourceFile)) {
ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => { // There's an ignore marker on this node, so the diagnostic should not be reported.
if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) { return null;
return null; }
}
const commentText = sourceFile.text.substring(pos, end); const span = readSpanComment(sourceFile, node);
return parseSourceSpanComment(commentText);
}) || null;
if (span !== null) { if (span !== null) {
// Once the positional information has been extracted, search further up the TCB to extract // Once the positional information has been extracted, search further up the TCB to extract
// the unique id that is attached with the TCB's function declaration. // the unique id that is attached with the TCB's function declaration.
Expand All @@ -243,17 +256,21 @@ function findSourceLocation(node: ts.Node, sourceFile: ts.SourceFile): SourceLoc


function getTemplateId(node: ts.Node, sourceFile: ts.SourceFile): TemplateId|null { function getTemplateId(node: ts.Node, sourceFile: ts.SourceFile): TemplateId|null {
// Walk up to the function declaration of the TCB, the file information is attached there. // Walk up to the function declaration of the TCB, the file information is attached there.
let tcb = node; while (!ts.isFunctionDeclaration(node)) {
while (!ts.isFunctionDeclaration(tcb)) { if (hasIgnoreMarker(node, sourceFile)) {
tcb = tcb.parent; // There's an ignore marker on this node, so the diagnostic should not be reported.
return null;
}
node = node.parent;


// Bail once we have reached the root. // Bail once we have reached the root.
if (tcb === undefined) { if (node === undefined) {
return null; return null;
} }
} }


return ts.forEachLeadingCommentRange(sourceFile.text, tcb.getFullStart(), (pos, end, kind) => { const start = node.getFullStart();
return ts.forEachLeadingCommentRange(sourceFile.text, start, (pos, end, kind) => {
if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) { if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
return null; return null;
} }
Expand All @@ -262,13 +279,29 @@ function getTemplateId(node: ts.Node, sourceFile: ts.SourceFile): TemplateId|nul
}) as TemplateId || null; }) as TemplateId || null;
} }


const parseSpanComment = /^\/\*(\d+),(\d+)\*\/$/; const parseSpanComment = /^(\d+),(\d+)$/;


function parseSourceSpanComment(commentText: string): AbsoluteSourceSpan|null { function readSpanComment(sourceFile: ts.SourceFile, node: ts.Node): AbsoluteSourceSpan|null {
const match = commentText.match(parseSpanComment); return ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => {
if (match === null) { if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
return null; return null;
} }
const commentText = sourceFile.text.substring(pos + 2, end - 2);
const match = commentText.match(parseSpanComment);
if (match === null) {
return null;
}

return new AbsoluteSourceSpan(+match[1], +match[2]);
}) || null;
}


return new AbsoluteSourceSpan(+match[1], +match[2]); function hasIgnoreMarker(node: ts.Node, sourceFile: ts.SourceFile): boolean {
return ts.forEachTrailingCommentRange(sourceFile.text, node.getEnd(), (pos, end, kind) => {
if (kind !== ts.SyntaxKind.MultiLineCommentTrivia) {
return null;
}
const commentText = sourceFile.text.substring(pos + 2, end - 2);
return commentText === IGNORE_MARKER;
}) === true;
} }
10 changes: 7 additions & 3 deletions packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
Expand Up @@ -10,7 +10,7 @@ import {AST, ASTWithSource, AstVisitor, Binary, BindingPipe, Chain, Conditional,
import * as ts from 'typescript'; import * as ts from 'typescript';


import {TypeCheckingConfig} from './api'; import {TypeCheckingConfig} from './api';
import {addParseSpanInfo, wrapForDiagnostics} from './diagnostics'; import {addParseSpanInfo, ignoreDiagnostics, wrapForDiagnostics} from './diagnostics';


export const NULL_AS_ANY = export const NULL_AS_ANY =
ts.createAsExpression(ts.createNull(), ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)); ts.createAsExpression(ts.createNull(), ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword));
Expand Down Expand Up @@ -222,11 +222,13 @@ class AstTranslator implements AstVisitor {
// See the comment in SafePropertyRead above for an explanation of the need for the non-null // See the comment in SafePropertyRead above for an explanation of the need for the non-null
// assertion here. // assertion here.
const receiver = wrapForDiagnostics(this.translate(ast.receiver)); const receiver = wrapForDiagnostics(this.translate(ast.receiver));
const guard = ts.getMutableClone(receiver);
ignoreDiagnostics(guard);
const method = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name); const method = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
const args = ast.args.map(expr => this.translate(expr)); const args = ast.args.map(expr => this.translate(expr));
const expr = ts.createCall(method, undefined, args); const expr = ts.createCall(method, undefined, args);
const whenNull = this.config.strictSafeNavigationTypes ? UNDEFINED : NULL_AS_ANY; const whenNull = this.config.strictSafeNavigationTypes ? UNDEFINED : NULL_AS_ANY;
const node = safeTernary(receiver, expr, whenNull); const node = safeTernary(guard, expr, whenNull);
addParseSpanInfo(node, ast.sourceSpan); addParseSpanInfo(node, ast.sourceSpan);
return node; return node;
} }
Expand All @@ -237,9 +239,11 @@ class AstTranslator implements AstVisitor {
// assertion is necessary because in practice 'a' may be a method call expression, which won't // assertion is necessary because in practice 'a' may be a method call expression, which won't
// have a narrowed type when repeated in the ternary true branch. // have a narrowed type when repeated in the ternary true branch.
const receiver = wrapForDiagnostics(this.translate(ast.receiver)); const receiver = wrapForDiagnostics(this.translate(ast.receiver));
const guard = ts.getMutableClone(receiver);
ignoreDiagnostics(guard);
const expr = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name); const expr = ts.createPropertyAccess(ts.createNonNullExpression(receiver), ast.name);
const whenNull = this.config.strictSafeNavigationTypes ? UNDEFINED : NULL_AS_ANY; const whenNull = this.config.strictSafeNavigationTypes ? UNDEFINED : NULL_AS_ANY;
const node = safeTernary(receiver, expr, whenNull); const node = safeTernary(guard, expr, whenNull);
addParseSpanInfo(node, ast.sourceSpan); addParseSpanInfo(node, ast.sourceSpan);
return node; return node;
} }
Expand Down
Expand Up @@ -13,7 +13,7 @@ import {Reference} from '../../imports';
import {ClassDeclaration} from '../../reflection'; import {ClassDeclaration} from '../../reflection';


import {TemplateId, TypeCheckBlockMetadata, TypeCheckableDirectiveMeta} from './api'; import {TemplateId, TypeCheckBlockMetadata, TypeCheckableDirectiveMeta} from './api';
import {addParseSpanInfo, addTemplateId, wrapForDiagnostics} from './diagnostics'; import {addParseSpanInfo, addTemplateId, ignoreDiagnostics, wrapForDiagnostics} from './diagnostics';
import {DomSchemaChecker} from './dom'; import {DomSchemaChecker} from './dom';
import {Environment} from './environment'; import {Environment} from './environment';
import {NULL_AS_ANY, astToTypescript} from './expression'; import {NULL_AS_ANY, astToTypescript} from './expression';
Expand Down Expand Up @@ -217,6 +217,10 @@ class TcbTemplateBodyOp extends TcbOp {
// If there is such a binding, generate an expression for it. // If there is such a binding, generate an expression for it.
const expr = tcbExpression(boundInput.value, this.tcb, this.scope); const expr = tcbExpression(boundInput.value, this.tcb, this.scope);


// The expression has already been checked in the type constructor invocation, so
// it should be ignored when used within a template guard.
ignoreDiagnostics(expr);

if (guard.type === 'binding') { if (guard.type === 'binding') {
// Use the binding expression itself as guard. // Use the binding expression itself as guard.
directiveGuards.push(expr); directiveGuards.push(expr);
Expand Down
46 changes: 43 additions & 3 deletions packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts
Expand Up @@ -43,7 +43,7 @@ runInEachFileSystem(() => {
`<div *ngFor="let person of persons; let idx=index">{{ render(idx) }}</div>`, ` `<div *ngFor="let person of persons; let idx=index">{{ render(idx) }}</div>`, `
class TestComponent { class TestComponent {
persons: {}[]; persons: {}[];
render(input: string): string { return input; } render(input: string): string { return input; }
}`, }`,
[ngForDeclaration()], [ngForDts()]); [ngForDeclaration()], [ngForDts()]);
Expand All @@ -58,7 +58,7 @@ runInEachFileSystem(() => {
`<div *ngFor="let person of persons;">{{ render(person.namme) }}</div>`, ` `<div *ngFor="let person of persons;">{{ render(person.namme) }}</div>`, `
class TestComponent { class TestComponent {
persons: any; persons: any;
render(input: string): string { return input; } render(input: string): string { return input; }
}`, }`,
[ngForDeclaration()], [ngForDts()]); [ngForDeclaration()], [ngForDts()]);
Expand Down Expand Up @@ -194,6 +194,46 @@ runInEachFileSystem(() => {
]); ]);
}); });


it('does not repeat diagnostics for errors within LHS of safe-navigation operator', () => {
const messages = diagnose(`{{ personn?.name }} {{ personn?.getName() }}`, `
class TestComponent {
person: {
name: string;
getName: () => string;
} | null;
}`);

expect(messages).toEqual([
`synthetic.html(1, 4): Property 'personn' does not exist on type 'TestComponent'. Did you mean 'person'?`,
`synthetic.html(1, 24): Property 'personn' does not exist on type 'TestComponent'. Did you mean 'person'?`,
]);
});

it('does not repeat diagnostics for errors used in template guard expressions', () => {
const messages = diagnose(
`<div *guard="personn.name"></div>`, `
class GuardDir {
static ngTemplateGuard_guard: 'binding';
}
class TestComponent {
person: {
name: string;
};
}`,
[{
type: 'directive',
name: 'GuardDir',
selector: '[guard]',
inputs: {'guard': 'guard'},
ngTemplateGuards: [{inputName: 'guard', type: 'binding'}]
}]);

expect(messages).toEqual([
`synthetic.html(1, 14): Property 'personn' does not exist on type 'TestComponent'. Did you mean 'person'?`,
]);
});

it('does not produce diagnostics for user code', () => { it('does not produce diagnostics for user code', () => {
const messages = diagnose(`{{ person.name }}`, ` const messages = diagnose(`{{ person.name }}`, `
class TestComponent { class TestComponent {
Expand Down Expand Up @@ -313,7 +353,7 @@ runInEachFileSystem(() => {
<div> <div>
<img [src]="srcc" <img [src]="srcc"
[height]="heihgt"> [height]="heihgt">
</div> </div>
`, `,
` `
class TestComponent { class TestComponent {
Expand Down
Expand Up @@ -97,14 +97,15 @@ describe('type check blocks diagnostics', () => {
it('should annotate safe property access', () => { it('should annotate safe property access', () => {
const TEMPLATE = `{{ a?.b }}`; const TEMPLATE = `{{ a?.b }}`;
expect(tcbWithSpans(TEMPLATE)) expect(tcbWithSpans(TEMPLATE))
.toContain('(((ctx).a /*3,4*/) != null ? ((ctx).a /*3,4*/)!.b : undefined) /*3,8*/;'); .toContain(
'(((ctx).a /*3,4*/) /*ignore*/ != null ? ((ctx).a /*3,4*/)!.b : undefined) /*3,8*/;');
}); });


it('should annotate safe method calls', () => { it('should annotate safe method calls', () => {
const TEMPLATE = `{{ a?.method(b) }}`; const TEMPLATE = `{{ a?.method(b) }}`;
expect(tcbWithSpans(TEMPLATE)) expect(tcbWithSpans(TEMPLATE))
.toContain( .toContain(
'(((ctx).a /*3,4*/) != null ? ((ctx).a /*3,4*/)!.method((ctx).b /*13,14*/) : undefined) /*3,16*/;'); '(((ctx).a /*3,4*/) /*ignore*/ != null ? ((ctx).a /*3,4*/)!.method((ctx).b /*13,14*/) : undefined) /*3,16*/;');
}); });


it('should annotate $any casts', () => { it('should annotate $any casts', () => {
Expand Down

0 comments on commit d6edeab

Please sign in to comment.