perf(compiler-cli): perform template type-checking incrementally (#36211)

This optimization builds on a lot of prior work to finally make type-
checking of templates incremental.

Incrementality requires two main components:
- the ability to reuse work from a prior compilation.
- the ability to know when changes in the current program invalidate that
  prior work.

Prior to this commit, on every type-checking pass the compiler would
generate new .ngtypecheck files for each original input file in the program.

1. (Build #1 main program): empty .ngtypecheck files generated for each
   original input file.

2. (Build #1 type-check program): .ngtypecheck contents overridden for those
   which have corresponding components that need type-checked.

3. (Build #2 main program): throw away old .ngtypecheck files and generate
   new empty ones.

4. (Build #2 type-check program): same as step 2.

With this commit, the `IncrementalDriver` now tracks template type-checking
_metadata_ for each input file. The metadata contains information about
source mappings for generated type-checking code, as well as some
diagnostics which were discovered at type-check analysis time. The actual
type-checking code is stored in the TypeScript AST for type-checking files,
which is now re-used between programs as follows:

1. (Build #1 main program): empty .ngtypecheck files generated for each
   original input file.

2. (Build #1 type-check program): .ngtypecheck contents overridden for those
   which have corresponding components that need type-checked, and the
   metadata registered in the `IncrementalDriver`.

3. (Build #2 main program): The `TypeCheckShimGenerator` now reuses _all_
   .ngtypecheck `ts.SourceFile` shims from build #1's type-check program in
   the construction of build #2's main program. Some of the contents of
   these files might be stale (if a component's template changed, for
   example), but wholesale reuse here prevents unnecessary changes in the
   contents of the program at this point and makes TypeScript's job a lot
   easier.

4. (Build #2 type-check program): For those input files which have not
   "logically changed" (meaning components within are semantically the same
   as they were before), the compiler will re-use the type-check file
   metadata from build #1, and _not_ generate a new .ngtypecheck shim.
   For components which have logically changed or where the previous
   .ngtypecheck contents cannot otherwise be reused, code generation happens
   as before.

PR Close #36211
This commit is contained in:
Alex Rickabaugh
2020-03-19 11:29:58 -07:00
parent b861e9c0ac
commit ecffc3557f
18 changed files with 451 additions and 72 deletions

View File

@ -70,6 +70,7 @@ runInEachFileSystem(() => {
});
it('should rebuild components that have changed', () => {
env.tsconfig({strictTemplates: true});
env.write('component1.ts', `
import {Component} from '@angular/core';
@ -471,6 +472,261 @@ runInEachFileSystem(() => {
env.invalidateCachedFile('test.ts');
env.driveMain();
});
describe('template type-checking', () => {
beforeEach(() => {
env.tsconfig({strictTemplates: true});
});
it('should repeat type errors across rebuilds, even if nothing has changed', () => {
// This test verifies that when a project is rebuilt multiple times with no changes, all
// template diagnostics are produced each time. Different types of errors are checked:
// - a general type error
// - an unmatched binding
// - a DOM schema error
env.write('component.ts', `
import {Component} from '@angular/core';
@Component({
selector: 'test-cmp',
template: \`
{{notAProperty}}
<not-a-component></not-a-component>
<div [notMatched]="1"></div>
\`,
})
export class TestCmp {}
`);
let diags = env.driveDiagnostics();
// Should get a diagnostic for each line in the template.
expect(diags.length).toBe(3);
// Now rebuild without any changes, and verify they're still produced.
diags = env.driveDiagnostics();
expect(diags.length).toBe(3);
// If it's worth testing, it's worth overtesting.
//
// Actually, the above only tests the transition from "initial" to "incremental"
// compilation. The next build verifies that an "incremental to incremental" transition
// preserves the diagnostics as well.
diags = env.driveDiagnostics();
expect(diags.length).toBe(3);
});
it('should pick up errors caused by changing an unrelated interface', () => {
// The premise of this test is that `iface.ts` declares an interface which is used to type
// a property of a component. The interface is then changed in a subsequent compilation in
// a way that introduces a type error in the template. The test verifies the resulting
// diagnostic is produced.
env.write('iface.ts', `
export interface SomeType {
field: string;
}
`);
env.write('component.ts', `
import {Component} from '@angular/core';
import {SomeType} from './iface';
@Component({
selector: 'test-cmp',
template: '{{ doSomething(value.field) }}',
})
export class TestCmp {
value!: SomeType;
// Takes a string value only.
doSomething(param: string): string {
return param;
}
}
`);
expect(env.driveDiagnostics().length).toBe(0);
env.flushWrittenFileTracking();
// Change the interface.
env.write('iface.ts', `
export interface SomeType {
field: number;
}
`);
expect(env.driveDiagnostics().length).toBe(1);
});
it('should recompile when a remote change happens to a scope', () => {
// The premise of this test is that the component Cmp has a template error (a binding to an
// unknown property). Cmp is in ModuleA, which imports ModuleB, which declares Dir that has
// the property. Because ModuleB doesn't export Dir, it's not visible to Cmp - hence the
// error.
// In the test, during the incremental rebuild Dir is exported from ModuleB. This is a
// change to the scope of ModuleA made by changing ModuleB (hence, a "remote change"). The
// test verifies that incremental template type-checking.
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({
selector: 'test-cmp',
template: '<div dir [someInput]="1"></div>',
})
export class Cmp {}
`);
env.write('module-a.ts', `
import {NgModule} from '@angular/core';
import {Cmp} from './cmp';
import {ModuleB} from './module-b';
@NgModule({
declarations: [Cmp],
imports: [ModuleB],
})
export class ModuleA {}
`);
// Declare ModuleB and a directive Dir, but ModuleB does not yet export Dir.
env.write('module-b.ts', `
import {NgModule} from '@angular/core';
import {Dir} from './dir';
@NgModule({
declarations: [Dir],
})
export class ModuleB {}
`);
env.write('dir.ts', `
import {Directive, Input} from '@angular/core';
@Directive({selector: '[dir]'})
export class Dir {
@Input() someInput!: any;
}
`);
let diags = env.driveDiagnostics();
// Should get a diagnostic about [dir] not being a valid binding.
expect(diags.length).toBe(1);
// As a precautionary check, run the build a second time with no changes, to ensure the
// diagnostic is repeated.
diags = env.driveDiagnostics();
// Should get a diagnostic about [dir] not being a valid binding.
expect(diags.length).toBe(1);
// Modify ModuleB to now export Dir.
env.write('module-b.ts', `
import {NgModule} from '@angular/core';
import {Dir} from './dir';
@NgModule({
declarations: [Dir],
exports: [Dir],
})
export class ModuleB {}
`);
diags = env.driveDiagnostics();
// Diagnostic should be gone, as the error has been fixed.
expect(diags.length).toBe(0);
});
describe('inline operations', () => {
it('should still pick up on errors from inlined type check blocks', () => {
// In certain cases the template type-checker has to inline type checking blocks into user
// code, instead of placing it in a parallel template type-checking file. In these cases
// incremental checking cannot be used, and the type-checking code must be regenerated on
// each build. This test verifies that the above mechanism works properly, by performing
// type-checking on an unexported class (not exporting the class forces the inline
// checking de-optimization).
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({
selector: 'test-cmp',
template: '{{test}}',
})
class Cmp {}
`);
// On the first compilation, an error should be produced.
let diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
// Next, two more builds are run, one with no changes made to the file, and the other with
// changes made that should remove the error.
// The error should still be present when rebuilding.
diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
// Now, correct the error by adding the 'test' property to the component.
env.write('cmp.ts', `
import {Component} from '@angular/core';
@Component({
selector: 'test-cmp',
template: '{{test}}',
})
class Cmp {
test!: string;
}
`);
env.driveMain();
// The error should be gone.
diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
it('should still pick up on errors caused by inlined type constructors', () => {
// In certain cases the template type-checker cannot generate a type constructor for a
// directive within the template type-checking file which requires it, but must inline the
// type constructor within its original source file. In these cases, template type
// checking cannot be performed incrementally. This test verifies that such cases still
// work correctly, by repeatedly performing diagnostics on a component which depends on a
// directive with an inlined type constructor.
env.write('dir.ts', `
import {Directive, Input} from '@angular/core';
export interface Keys {
alpha: string;
beta: string;
}
@Directive({
selector: '[dir]'
})
export class Dir<T extends keyof Keys> {
// The use of 'keyof' in the generic bound causes a deopt to an inline type
// constructor.
@Input() dir: T;
}
`);
env.write('cmp.ts', `
import {Component, NgModule} from '@angular/core';
import {Dir} from './dir';
@Component({
selector: 'test-cmp',
template: '<div dir="gamma"></div>',
})
export class Cmp {}
@NgModule({
declarations: [Cmp, Dir],
})
export class Module {}
`);
let diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toContain('"alpha" | "beta"');
// On a rebuild, the same errors should be present.
diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toContain('"alpha" | "beta"');
});
});
});
});
function setupFooBarProgram(env: NgtscTestEnvironment) {