refactor(compiler): type check contents of control flow blocks (#51570)

Adds type checking for the contents of `if`, `switch` and `for` blocks.

**Note:** this is just an initial implementation to get some basic type checking working and to figure out the testing setup. We'll need special TCB structures for this syntax so that we can support type narrowing.

PR Close #51570
This commit is contained in:
Kristiyan Kostadinov 2023-08-30 11:51:39 +02:00 committed by Jessica Janiuk
parent 98d98f2c94
commit 0c8917b348
18 changed files with 264 additions and 4 deletions

View file

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {AST, BindingPipe, BindingType, BoundTarget, Call, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstDeferredBlock, TmplAstElement, TmplAstIcu, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable, TransplantedType} from '@angular/compiler';
import {AST, BindingPipe, BindingType, BoundTarget, Call, DYNAMIC_TYPE, ImplicitReceiver, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafeCall, SafePropertyRead, SchemaMetadata, ThisReceiver, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstDeferredBlock, TmplAstElement, TmplAstForLoopBlock, TmplAstIcu, TmplAstIfBlock, TmplAstNode, TmplAstReference, TmplAstSwitchBlock, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable, TransplantedType} from '@angular/compiler';
import ts from 'typescript';
import {Reference} from '../../imports';
@ -1510,6 +1510,20 @@ class Scope {
node.placeholder !== null && this.appendChildren(node.placeholder);
node.loading !== null && this.appendChildren(node.loading);
node.error !== null && this.appendChildren(node.error);
} else if (node instanceof TmplAstIfBlock) {
// TODO(crisbeto): type check the branch expression.
for (const branch of node.branches) {
this.appendChildren(branch);
}
} else if (node instanceof TmplAstSwitchBlock) {
// TODO(crisbeto): type check switch condition
for (const currentCase of node.cases) {
this.appendChildren(currentCase);
}
} else if (node instanceof TmplAstForLoopBlock) {
// TODO(crisbeto): type check loop expression, context variables and trackBy
this.appendChildren(node);
node.empty && this.appendChildren(node.empty);
} else if (node instanceof TmplAstBoundText) {
this.opQueue.push(new TcbTextInterpolationOp(this.tcb, this, node));
} else if (node instanceof TmplAstIcu) {

View file

@ -1379,4 +1379,61 @@ describe('type check blocks', () => {
'"" + ((this).main()); "" + ((this).placeholder()); "" + ((this).loading()); "" + ((this).error());');
});
});
// TODO(crisbeto): tests for the bindings of conditionals and context variables.
describe('conditional blocks', () => {
// TODO(crisbeto): temporary utility while conditional blocks are disabled by default
function conditionalTcb(template: string): string {
return tcb(
template, undefined, undefined, undefined,
{enabledBlockTypes: new Set(['if', 'switch'])});
}
it('should generate bindings inside if block', () => {
const TEMPLATE = `
{#if expr}
{{main()}}
{:else if expr1}{{one()}}
{:else if expr2}{{two()}}
{:else}{{other()}}
{/if}
`;
expect(conditionalTcb(TEMPLATE))
.toContain(
'"" + ((this).main()); "" + ((this).one()); "" + ((this).two()); "" + ((this).other());');
});
it('should generate bindings inside switch block', () => {
const TEMPLATE = `
{#switch expr}
{:case 1}{{one()}}
{:case 2}{{two()}}
{:default}{{default()}}
{/switch}
`;
expect(conditionalTcb(TEMPLATE))
.toContain('"" + ((this).one()); "" + ((this).two()); "" + ((this).default());');
});
});
// TODO(crisbeto): tests for the for loop expression and context variables
describe('for loop blocks', () => {
// TODO(crisbeto): temporary utility while for loop blocks are disabled by default
function loopTcb(template: string): string {
return tcb(template, undefined, undefined, undefined, {enabledBlockTypes: new Set(['for'])});
}
it('should generate bindings inside for loop blocks', () => {
const TEMPLATE = `
{#for item of items; track item}
{{main()}}
{:empty}{{empty()}}
{/for}
`;
expect(loopTcb(TEMPLATE)).toContain('"" + ((this).main()); "" + ((this).empty());');
});
});
});

View file

@ -530,6 +530,7 @@ import * as i0 from "@angular/core";
export declare class MyApp {
message: string;
value: () => number;
alias: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -579,6 +580,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
import * as i0 from "@angular/core";
export declare class MyApp {
value: () => number;
root: any;
inner: any;
innermost: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -592,7 +596,7 @@ export class MyApp {
constructor() {
this.value = () => 1;
}
log(_) { }
log(..._) { }
}
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: `
@ -633,7 +637,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
import * as i0 from "@angular/core";
export declare class MyApp {
value: () => number;
log(_: any): void;
log(..._: any[]): void;
root: any;
inner: any;
innermost: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -677,6 +684,7 @@ export declare class MyApp {
items: {
name: string;
}[];
item: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -726,6 +734,7 @@ export declare class MyApp {
items: {
name: string;
}[];
item: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -769,6 +778,7 @@ export declare class MyApp {
items: {
name: string;
}[];
item: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -812,6 +822,7 @@ export declare class MyApp {
items: {
name: string;
}[];
item: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -866,6 +877,8 @@ export declare class MyApp {
name: string;
subItems: string[];
}[];
item: any;
subitem: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -921,6 +934,13 @@ import * as i0 from "@angular/core";
export declare class MyApp {
message: string;
items: never[];
item: any;
$index: any;
$first: any;
$last: any;
$even: any;
$odd: any;
$count: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -976,6 +996,12 @@ import * as i0 from "@angular/core";
export declare class MyApp {
message: string;
items: never[];
idx: any;
f: any;
l: any;
ev: any;
o: any;
co: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -1036,6 +1062,9 @@ export declare class MyApp {
name: string;
subItems: string[];
}[];
item: any;
outerCount: any;
$count: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -1083,6 +1112,11 @@ export declare class MyApp {
message: string;
items: never[];
log(..._: any[]): void;
item: any;
ev: any;
$index: any;
$first: any;
$count: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -1112,6 +1146,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
import * as i0 from "@angular/core";
export declare class MyApp {
items: never[];
item: any;
$odd: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
@ -1151,6 +1187,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
import * as i0 from "@angular/core";
export declare class MyApp {
items: string[];
item: any;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}

View file

@ -11,4 +11,5 @@ import {Component} from '@angular/core';
export class MyApp {
message = 'hello';
items = [{name: 'one'}, {name: 'two'}, {name: 'three'}];
item: any; // TODO(crisbeto): remove this once template type checking is full implemented.
}

View file

@ -18,4 +18,12 @@ import {Component} from '@angular/core';
export class MyApp {
message = 'hello';
items = [];
// TODO(crisbeto): remove this once template type checking is full implemented.
idx: any;
f: any;
l: any;
ev: any;
o: any;
co: any;
}

View file

@ -11,4 +11,5 @@ import {Component} from '@angular/core';
})
export class MyApp {
items = ['one', 'two', 'three'];
item: any; // TODO(crisbeto): remove this once template type checking is full implemented.
}

View file

@ -18,4 +18,13 @@ import {Component} from '@angular/core';
export class MyApp {
message = 'hello';
items = [];
// TODO(crisbeto): remove this once template type checking is full implemented.
item: any;
$index: any;
$first: any;
$last: any;
$even: any;
$odd: any;
$count: any;
}

View file

@ -14,4 +14,11 @@ export class MyApp {
message = 'hello';
items = [];
log(..._: any[]) {}
// TODO(crisbeto): remove this once template type checking is full implemented.
item: any;
ev: any;
$index: any;
$first: any;
$count: any;
}

View file

@ -11,4 +11,5 @@ import {Component} from '@angular/core';
export class MyApp {
message = 'hello';
items = [{name: 'one'}, {name: 'two'}, {name: 'three'}];
item: any; // TODO(crisbeto): remove this once template type checking is full implemented.
}

View file

@ -11,4 +11,5 @@ import {Component} from '@angular/core';
export class MyApp {
message = 'hello';
items = [{name: 'one'}, {name: 'two'}, {name: 'three'}];
item: any; // TODO(crisbeto): remove this once template type checking is full implemented.
}

View file

@ -5,4 +5,7 @@ import {Component} from '@angular/core';
})
export class MyApp {
items = [];
// TODO(crisbeto): remove this once template type checking is full implemented.
item: any;
$odd: any;
}

View file

@ -14,4 +14,5 @@ import {Component} from '@angular/core';
export class MyApp {
message = 'hello';
items = [{name: 'one'}, {name: 'two'}, {name: 'three'}];
item: any; // TODO(crisbeto): remove this once template type checking is full implemented.
}

View file

@ -15,4 +15,8 @@ import {Component} from '@angular/core';
})
export class MyApp {
value = () => 1;
// TODO(crisbeto): remove this once template type checking is full implemented.
root: any;
inner: any;
innermost: any;
}

View file

@ -17,5 +17,9 @@ import {Component} from '@angular/core';
})
export class MyApp {
value = () => 1;
log(_: any) {}
log(..._: any[]) {}
// TODO(crisbeto): remove this once template type checking is full implemented.
root: any;
inner: any;
innermost: any;
}

View file

@ -11,4 +11,6 @@ import {Component} from '@angular/core';
export class MyApp {
message = 'hello';
value = () => 1;
// TODO(crisbeto): remove this once template type checking is full implemented.
alias: any;
}

View file

@ -18,4 +18,8 @@ export class MyApp {
{name: 'two', subItems: ['sub one', 'sub two', 'sub three']},
{name: 'three', subItems: ['sub one', 'sub two', 'sub three']},
];
// TODO(crisbeto): remove this once template type checking is full implemented.
item: any;
subitem: any;
}

View file

@ -21,4 +21,9 @@ export class MyApp {
{name: 'two', subItems: ['sub one', 'sub two', 'sub three']},
{name: 'three', subItems: ['sub one', 'sub two', 'sub three']},
];
// TODO(crisbeto): remove this once template type checking is full implemented.
item: any;
outerCount: any;
$count: any;
}

View file

@ -3656,5 +3656,106 @@ suppress
]);
});
});
describe('conditional blocks', () => {
beforeEach(() => {
env.tsconfig({_enabledBlockTypes: ['if', 'switch']});
});
// TODO(crisbeto): test to check the bindings of the branches.
// TODO(crisbeto): test for an `if` block with an `as` assignment.
// TODO(crisbeto): test for type narrowing.
it('should check bindings inside if blocks', () => {
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({
template: \`
{#if expr}
{{does_not_exist_main}}
{:else if expr1}{{does_not_exist_one}}
{:else if expr2}{{does_not_exist_two}}
{:else}{{does_not_exist_else}}
{/if}
\`,
standalone: true,
})
export class Main {
expr = false;
expr1 = false;
expr2 = false;
}
`);
const diags = env.driveDiagnostics();
expect(diags.map(d => ts.flattenDiagnosticMessageText(d.messageText, ''))).toEqual([
`Property 'does_not_exist_main' does not exist on type 'Main'.`,
`Property 'does_not_exist_one' does not exist on type 'Main'.`,
`Property 'does_not_exist_two' does not exist on type 'Main'.`,
`Property 'does_not_exist_else' does not exist on type 'Main'.`,
]);
});
// TODO(crisbeto): test to check the bindings of the cases.
// TODO(crisbeto): test for type narrowing.
it('should check bindings inside switch blocks', () => {
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({
template: \`
{#switch expr}
{:case 1}{{does_not_exist_one}}
{:case 2}{{does_not_exist_two}}
{:default}{{does_not_exist_default}}
{/switch}
\`,
standalone: true,
})
export class Main {
expr: any;
}
`);
const diags = env.driveDiagnostics();
expect(diags.map(d => ts.flattenDiagnosticMessageText(d.messageText, ''))).toEqual([
`Property 'does_not_exist_one' does not exist on type 'Main'.`,
`Property 'does_not_exist_two' does not exist on type 'Main'.`,
`Property 'does_not_exist_default' does not exist on type 'Main'.`,
]);
});
});
// TODO(crisbeto): test for the loop expression binding
// TODO(crisbeto): test for the track expression.
// TODO(crisbeto): test for the context variables ($index, $odd etc).
describe('for loop blocks', () => {
beforeEach(() => {
env.tsconfig({_enabledBlockTypes: ['for']});
});
it('should check bindings inside of for loop blocks', () => {
env.write('test.ts', `
import {Component} from '@angular/core';
@Component({
template: \`
{#for item of items; track item}
{{does_not_exist_main}}
{:empty}{{does_not_exist_empty}}
{/for}
\`,
standalone: true,
})
export class Main {}
`);
const diags = env.driveDiagnostics();
expect(diags.map(d => ts.flattenDiagnosticMessageText(d.messageText, ''))).toEqual([
`Property 'does_not_exist_main' does not exist on type 'Main'.`,
`Property 'does_not_exist_empty' does not exist on type 'Main'.`,
]);
});
});
});
});