The Angular compiler has to actively keep default import statements alive if they were only used in type-only positions, but have been emitted as value expressions for DI purposes. A problem occurred in incremental recompilations, where the relationship between an identifier usage and its corresponding default import would not be considered. This could result in the removal of the default import statement and caused a `ReferenceError` at runtime. This commit fixes the issue by storing the association from an identifier to its default import declaration on the source file itself, instead of within the `DefaultImportTracker` instance. The `DefaultImportTracker` instance is only valid for a single compilation, whereas the association from an identifier to a default import declaration is valid as long as the `ts.SourceFile` is the same instance. A subsequent commit refactor the `DefaultImportTracker` to no longer be responsible for registering the association, as its lifetime is conceptually too short to do so. Fixes #41377 PR Close #41557
911 lines
32 KiB
TypeScript
911 lines
32 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.io/license
|
|
*/
|
|
|
|
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
|
|
import {loadStandardTestFiles} from '../../src/ngtsc/testing';
|
|
|
|
import {NgtscTestEnvironment} from './env';
|
|
|
|
const testFiles = loadStandardTestFiles();
|
|
|
|
runInEachFileSystem(() => {
|
|
describe('ngtsc incremental compilation', () => {
|
|
let env!: NgtscTestEnvironment;
|
|
|
|
beforeEach(() => {
|
|
env = NgtscTestEnvironment.setup(testFiles);
|
|
env.enableMultipleCompilations();
|
|
env.tsconfig();
|
|
});
|
|
|
|
it('should not crash if CLI does not provide getModifiedResourceFiles()', () => {
|
|
env.write('component1.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'cmp', templateUrl: './component1.template.html'})
|
|
export class Cmp1 {}
|
|
`);
|
|
env.write('component1.template.html', 'cmp1');
|
|
env.driveMain();
|
|
|
|
// Simulate a change to `component1.html`
|
|
env.flushWrittenFileTracking();
|
|
env.invalidateCachedFile('component1.html');
|
|
env.simulateLegacyCLICompilerHost();
|
|
env.driveMain();
|
|
});
|
|
|
|
it('should skip unchanged services', () => {
|
|
env.write('service.ts', `
|
|
import {Injectable} from '@angular/core';
|
|
|
|
@Injectable()
|
|
export class Service {}
|
|
`);
|
|
env.write('test.ts', `
|
|
import {Component} from '@angular/core';
|
|
import {Service} from './service';
|
|
|
|
@Component({selector: 'cmp', template: 'cmp'})
|
|
export class Cmp {
|
|
constructor(service: Service) {}
|
|
}
|
|
`);
|
|
env.driveMain();
|
|
env.flushWrittenFileTracking();
|
|
|
|
// Pretend a change was made to test.ts.
|
|
env.invalidateCachedFile('test.ts');
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
|
|
// The changed file should be recompiled, but not the service.
|
|
expect(written).toContain('/test.js');
|
|
expect(written).not.toContain('/service.js');
|
|
});
|
|
|
|
it('should rebuild components that have changed', () => {
|
|
env.tsconfig({strictTemplates: true});
|
|
env.write('component1.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'cmp', template: 'cmp'})
|
|
export class Cmp1 {}
|
|
`);
|
|
env.write('component2.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'cmp2', template: 'cmp'})
|
|
export class Cmp2 {}
|
|
`);
|
|
env.driveMain();
|
|
|
|
// Pretend a change was made to Cmp1
|
|
env.flushWrittenFileTracking();
|
|
env.invalidateCachedFile('component1.ts');
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).toContain('/component1.js');
|
|
expect(written).not.toContain('/component2.js');
|
|
});
|
|
|
|
it('should rebuild components whose templates have changed', () => {
|
|
env.write('component1.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'cmp', templateUrl: './component1.template.html'})
|
|
export class Cmp1 {}
|
|
`);
|
|
env.write('component1.template.html', 'cmp1');
|
|
env.write('component2.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'cmp2', templateUrl: './component2.template.html'})
|
|
export class Cmp2 {}
|
|
`);
|
|
env.write('component2.template.html', 'cmp2');
|
|
|
|
env.driveMain();
|
|
|
|
// Make a change to Cmp1 template
|
|
env.flushWrittenFileTracking();
|
|
env.write('component1.template.html', 'changed');
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).toContain('/component1.js');
|
|
expect(written).not.toContain('/component2.js');
|
|
});
|
|
|
|
it('should rebuild components whose partial-evaluation dependencies have changed', () => {
|
|
env.write('component1.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'cmp', template: 'cmp'})
|
|
export class Cmp1 {}
|
|
`);
|
|
env.write('component2.ts', `
|
|
import {Component} from '@angular/core';
|
|
import {SELECTOR} from './constants';
|
|
|
|
@Component({selector: SELECTOR, template: 'cmp'})
|
|
export class Cmp2 {}
|
|
`);
|
|
env.write('constants.ts', `
|
|
export const SELECTOR = 'cmp';
|
|
`);
|
|
env.driveMain();
|
|
|
|
// Pretend a change was made to SELECTOR
|
|
env.flushWrittenFileTracking();
|
|
env.invalidateCachedFile('constants.ts');
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).toContain('/constants.js');
|
|
expect(written).not.toContain('/component1.js');
|
|
expect(written).toContain('/component2.js');
|
|
});
|
|
|
|
it('should rebuild components whose imported dependencies have changed', () => {
|
|
setupFooBarProgram(env);
|
|
|
|
// Pretend a change was made to BarDir.
|
|
env.write('bar_directive.ts', `
|
|
import {Directive} from '@angular/core';
|
|
|
|
@Directive({selector: '[barr]'})
|
|
export class BarDir {}
|
|
`);
|
|
env.driveMain();
|
|
|
|
let written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).toContain('/bar_directive.js');
|
|
expect(written).toContain('/bar_component.js');
|
|
expect(written).toContain('/bar_module.js');
|
|
expect(written).not.toContain('/foo_component.js'); // BarDir is not exported by BarModule,
|
|
// so upstream NgModule is not affected
|
|
expect(written).not.toContain('/foo_pipe.js');
|
|
expect(written).not.toContain('/foo_module.js');
|
|
});
|
|
|
|
// https://github.com/angular/angular/issues/32416
|
|
it('should rebuild full NgModule scope when a dependency of a declaration has changed', () => {
|
|
env.write('component1.ts', `
|
|
import {Component} from '@angular/core';
|
|
import {SELECTOR} from './dep';
|
|
|
|
@Component({selector: SELECTOR, template: 'cmp'})
|
|
export class Cmp1 {}
|
|
`);
|
|
env.write('component2.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'cmp2', template: '<cmp></cmp>'})
|
|
export class Cmp2 {}
|
|
`);
|
|
env.write('dep.ts', `
|
|
export const SELECTOR = 'cmp';
|
|
`);
|
|
env.write('directive.ts', `
|
|
import {Directive} from '@angular/core';
|
|
|
|
@Directive({selector: 'dir'})
|
|
export class Dir {}
|
|
`);
|
|
env.write('pipe.ts', `
|
|
import {Pipe} from '@angular/core';
|
|
|
|
@Pipe({name: 'myPipe'})
|
|
export class MyPipe {}
|
|
`);
|
|
env.write('module.ts', `
|
|
import {NgModule, NO_ERRORS_SCHEMA} from '@angular/core';
|
|
import {Cmp1} from './component1';
|
|
import {Cmp2} from './component2';
|
|
import {Dir} from './directive';
|
|
import {MyPipe} from './pipe';
|
|
|
|
@NgModule({declarations: [Cmp1, Cmp2, Dir, MyPipe], schemas: [NO_ERRORS_SCHEMA]})
|
|
export class Mod {}
|
|
`);
|
|
env.driveMain();
|
|
|
|
// Pretend a change was made to 'dep'. Since the selector is updated this affects the NgModule
|
|
// scope, so all components in the module scope need to be recompiled.
|
|
env.flushWrittenFileTracking();
|
|
env.write('dep.ts', `
|
|
export const SELECTOR = 'cmp_updated';
|
|
`);
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).not.toContain('/directive.js');
|
|
expect(written).not.toContain('/pipe.js');
|
|
expect(written).not.toContain('/module.js');
|
|
expect(written).toContain('/component1.js');
|
|
expect(written).toContain('/component2.js');
|
|
expect(written).toContain('/dep.js');
|
|
});
|
|
|
|
it('should rebuild components where their NgModule declared dependencies have changed', () => {
|
|
setupFooBarProgram(env);
|
|
|
|
// Rename the pipe so components that use it need to be recompiled.
|
|
env.write('foo_pipe.ts', `
|
|
import {Pipe} from '@angular/core';
|
|
|
|
@Pipe({name: 'foo_changed'})
|
|
export class FooPipe {}
|
|
`);
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).not.toContain('/bar_directive.js');
|
|
expect(written).not.toContain('/bar_component.js');
|
|
expect(written).not.toContain('/bar_module.js');
|
|
expect(written).toContain('/foo_component.js');
|
|
expect(written).toContain('/foo_pipe.js');
|
|
expect(written).toContain('/foo_module.js');
|
|
});
|
|
|
|
it('should rebuild components where their NgModule has changed', () => {
|
|
setupFooBarProgram(env);
|
|
|
|
// Pretend a change was made to FooModule.
|
|
env.write('foo_module.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {FooCmp} from './foo_component';
|
|
import {FooPipe} from './foo_pipe';
|
|
import {BarModule} from './bar_module';
|
|
@NgModule({
|
|
declarations: [FooCmp], // removed FooPipe
|
|
imports: [BarModule],
|
|
})
|
|
export class FooModule {}
|
|
`);
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).not.toContain('/bar_directive.js');
|
|
expect(written).not.toContain('/bar_component.js');
|
|
expect(written).not.toContain('/bar_module.js');
|
|
expect(written).not.toContain('/foo_pipe.js');
|
|
expect(written).toContain('/foo_component.js');
|
|
expect(written).toContain('/foo_module.js');
|
|
});
|
|
|
|
it('should rebuild a component if one of its deep NgModule dependencies changes', () => {
|
|
// This test constructs a chain of NgModules:
|
|
// - EntryModule imports MiddleAModule
|
|
// - MiddleAModule exports MiddleBModule
|
|
// - MiddleBModule exports DirModule
|
|
// The last link (MiddleBModule exports DirModule) is not present initially, but is added
|
|
// during a recompilation.
|
|
//
|
|
// Since the dependency from EntryModule on the contents of MiddleBModule is not "direct"
|
|
// (meaning MiddleBModule is not discovered during analysis of EntryModule), this test is
|
|
// verifying that NgModule dependency tracking correctly accounts for this situation.
|
|
env.write('entry.ts', `
|
|
import {Component, NgModule} from '@angular/core';
|
|
import {MiddleAModule} from './middle-a';
|
|
|
|
@Component({
|
|
selector: 'test-cmp',
|
|
template: '<div dir>',
|
|
})
|
|
export class TestCmp {}
|
|
|
|
@NgModule({
|
|
declarations: [TestCmp],
|
|
imports: [MiddleAModule],
|
|
})
|
|
export class EntryModule {}
|
|
`);
|
|
env.write('middle-a.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {MiddleBModule} from './middle-b';
|
|
|
|
@NgModule({
|
|
exports: [MiddleBModule],
|
|
})
|
|
export class MiddleAModule {}
|
|
`);
|
|
env.write('middle-b.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
|
|
@NgModule({})
|
|
export class MiddleBModule {}
|
|
`);
|
|
env.write('dir_module.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {Dir} from './dir';
|
|
|
|
@NgModule({
|
|
declarations: [Dir],
|
|
exports: [Dir],
|
|
})
|
|
export class DirModule {}
|
|
`);
|
|
env.write('dir.ts', `
|
|
import {Directive} from '@angular/core';
|
|
|
|
@Directive({
|
|
selector: '[dir]',
|
|
})
|
|
export class Dir {}
|
|
`);
|
|
|
|
env.driveMain();
|
|
expect(env.getContents('entry.js')).not.toContain('Dir');
|
|
|
|
env.write('middle-b.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {DirModule} from './dir_module';
|
|
|
|
@NgModule({
|
|
exports: [DirModule],
|
|
})
|
|
export class MiddleBModule {}
|
|
`);
|
|
|
|
env.driveMain();
|
|
expect(env.getContents('entry.js')).toContain('Dir');
|
|
});
|
|
|
|
it('should rebuild a component if removed from an NgModule', () => {
|
|
// This test consists of a component with a dependency (the directive DepDir) provided via an
|
|
// NgModule. Initially this configuration is built, then the component is removed from its
|
|
// module (which removes DepDir from the component's scope) and a rebuild is performed.
|
|
// The compiler should re-emit the component without DepDir in its scope.
|
|
//
|
|
// This is a tricky scenario due to the backwards dependency arrow from a component to its
|
|
// module.
|
|
env.write('dep.ts', `
|
|
import {Directive, NgModule} from '@angular/core';
|
|
|
|
@Directive({selector: '[dep]'})
|
|
export class DepDir {}
|
|
|
|
@NgModule({
|
|
declarations: [DepDir],
|
|
exports: [DepDir],
|
|
})
|
|
export class DepModule {}
|
|
`);
|
|
|
|
env.write('cmp.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({
|
|
selector: 'test-cmp',
|
|
template: '<div dep></div>',
|
|
})
|
|
export class Cmp {}
|
|
`);
|
|
|
|
env.write('module.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {Cmp} from './cmp';
|
|
import {DepModule} from './dep';
|
|
|
|
@NgModule({
|
|
declarations: [Cmp],
|
|
imports: [DepModule],
|
|
})
|
|
export class Module {}
|
|
`);
|
|
|
|
env.driveMain();
|
|
env.flushWrittenFileTracking();
|
|
|
|
// Remove the component from the module and recompile.
|
|
env.write('module.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {DepModule} from './dep';
|
|
|
|
@NgModule({
|
|
declarations: [],
|
|
imports: [DepModule],
|
|
})
|
|
export class Module {}
|
|
`);
|
|
|
|
env.driveMain();
|
|
|
|
// After removing the component from the module, it should have been re-emitted without DepDir
|
|
// in its scope.
|
|
expect(env.getFilesWrittenSinceLastFlush()).toContain('/cmp.js');
|
|
expect(env.getContents('cmp.js')).not.toContain('DepDir');
|
|
});
|
|
|
|
it('should rebuild only a Component (but with the correct CompilationScope) if its template has changed',
|
|
() => {
|
|
setupFooBarProgram(env);
|
|
|
|
// Make a change to the template of BarComponent.
|
|
env.write('bar_component.html', '<div bar>changed</div>');
|
|
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).not.toContain('/bar_directive.js');
|
|
expect(written).toContain('/bar_component.js');
|
|
expect(written).not.toContain('/bar_module.js');
|
|
expect(written).not.toContain('/foo_component.js');
|
|
expect(written).not.toContain('/foo_pipe.js');
|
|
expect(written).not.toContain('/foo_module.js');
|
|
// Ensure that the used directives are included in the component's generated template.
|
|
expect(env.getContents('/built/bar_component.js')).toMatch(/directives:\s*\[.+\.BarDir\]/);
|
|
});
|
|
|
|
it('should rebuild everything if a typings file changes', () => {
|
|
setupFooBarProgram(env);
|
|
|
|
// Pretend a change was made to a typings file.
|
|
env.invalidateCachedFile('foo_selector.d.ts');
|
|
env.driveMain();
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).toContain('/bar_directive.js');
|
|
expect(written).toContain('/bar_component.js');
|
|
expect(written).toContain('/bar_module.js');
|
|
expect(written).toContain('/foo_component.js');
|
|
expect(written).toContain('/foo_pipe.js');
|
|
expect(written).toContain('/foo_module.js');
|
|
});
|
|
|
|
it('should compile incrementally with template type-checking turned on', () => {
|
|
env.tsconfig({fullTemplateTypeCheck: true});
|
|
env.write('main.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({template: ''})
|
|
export class MyComponent {}
|
|
`);
|
|
env.driveMain();
|
|
env.invalidateCachedFile('main.ts');
|
|
env.driveMain();
|
|
// If program reuse were configured incorrectly (as was responsible for
|
|
// https://github.com/angular/angular/issues/30079), this would have crashed.
|
|
});
|
|
|
|
// https://github.com/angular/angular/issues/38979
|
|
it('should retain ambient types provided by auto-discovered @types', () => {
|
|
// This test verifies that ambient types declared in node_modules/@types are still available
|
|
// in incremental compilations. In the below code, the usage of `require` should be valid
|
|
// in the original program and the incremental program.
|
|
env.tsconfig({fullTemplateTypeCheck: true});
|
|
env.write('node_modules/@types/node/index.d.ts', 'declare var require: any;');
|
|
env.write('main.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
require('path');
|
|
|
|
@Component({template: ''})
|
|
export class MyComponent {}
|
|
`);
|
|
env.driveMain();
|
|
env.invalidateCachedFile('main.ts');
|
|
const diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(0);
|
|
});
|
|
|
|
// https://github.com/angular/angular/pull/26036
|
|
it('should handle redirected source files', () => {
|
|
env.tsconfig({fullTemplateTypeCheck: true});
|
|
|
|
// This file structure has an identical version of "a" under the root node_modules and inside
|
|
// of "b". Because their package.json file indicates it is the exact same version of "a",
|
|
// TypeScript will transform the source file of "node_modules/b/node_modules/a/index.d.ts"
|
|
// into a redirect to "node_modules/a/index.d.ts". During incremental compilations, we must
|
|
// assure not to reintroduce "node_modules/b/node_modules/a/index.d.ts" as its redirected
|
|
// source file, but instead use its original file.
|
|
env.write('node_modules/a/index.js', `export class ServiceA {}`);
|
|
env.write('node_modules/a/index.d.ts', `export declare class ServiceA {}`);
|
|
env.write('node_modules/a/package.json', `{"name": "a", "version": "1.0"}`);
|
|
env.write('node_modules/b/node_modules/a/index.js', `export class ServiceA {}`);
|
|
env.write('node_modules/b/node_modules/a/index.d.ts', `export declare class ServiceA {}`);
|
|
env.write('node_modules/b/node_modules/a/package.json', `{"name": "a", "version": "1.0"}`);
|
|
env.write('node_modules/b/index.js', `export {ServiceA as ServiceB} from 'a';`);
|
|
env.write('node_modules/b/index.d.ts', `export {ServiceA as ServiceB} from 'a';`);
|
|
env.write('test.ts', `
|
|
import {Component} from '@angular/core';
|
|
import {ServiceA} from 'a';
|
|
import {ServiceB} from 'b';
|
|
|
|
@Component({template: ''})
|
|
export class MyComponent {}
|
|
`);
|
|
env.driveMain();
|
|
env.flushWrittenFileTracking();
|
|
|
|
// Pretend a change was made to test.ts. If redirect sources were introduced into the new
|
|
// program, this would fail due to an assertion failure in TS.
|
|
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 retain default imports that have been converted into a value expression', () => {
|
|
// This test defines the component `TestCmp` that has a default-imported class as
|
|
// constructor parameter, and uses `TestDir` in its template. An incremental compilation
|
|
// updates `TestDir` and changes its inputs, thereby triggering re-emit of `TestCmp` without
|
|
// performing re-analysis of `TestCmp`. The output of the re-emitted file for `TestCmp`
|
|
// should continue to have retained the default import.
|
|
env.write('service.ts', `
|
|
import {Injectable} from '@angular/core';
|
|
|
|
@Injectable({ providedIn: 'root' })
|
|
export default class DefaultService {}
|
|
`);
|
|
env.write('cmp.ts', `
|
|
import {Component, Directive} from '@angular/core';
|
|
import DefaultService from './service';
|
|
|
|
@Component({
|
|
template: '<div dir></div>',
|
|
})
|
|
export class TestCmp {
|
|
constructor(service: DefaultService) {}
|
|
}
|
|
`);
|
|
env.write('dir.ts', `
|
|
import {Directive} from '@angular/core';
|
|
|
|
@Directive({ selector: '[dir]' })
|
|
export class TestDir {}
|
|
`);
|
|
env.write('mod.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {TestDir} from './dir';
|
|
import {TestCmp} from './cmp';
|
|
|
|
@NgModule({ declarations: [TestDir, TestCmp] })
|
|
export class TestMod {}
|
|
`);
|
|
|
|
env.driveMain();
|
|
env.flushWrittenFileTracking();
|
|
|
|
// Update `TestDir` to change its inputs, triggering a re-emit of `TestCmp` that uses
|
|
// `TestDir`.
|
|
env.write('dir.ts', `
|
|
import {Directive} from '@angular/core';
|
|
|
|
@Directive({ selector: '[dir]', inputs: ['added'] })
|
|
export class TestDir {}
|
|
`);
|
|
env.driveMain();
|
|
|
|
// Verify that `TestCmp` was indeed re-emitted.
|
|
const written = env.getFilesWrittenSinceLastFlush();
|
|
expect(written).toContain('/dir.js');
|
|
expect(written).toContain('/cmp.js');
|
|
|
|
// Verify that the default import is still present.
|
|
const content = env.getContents('cmp.js');
|
|
expect(content).toContain(`import DefaultService from './service';`);
|
|
});
|
|
|
|
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(`Type '"gamma"' is not assignable to type 'keyof Keys'.`);
|
|
|
|
// On a rebuild, the same errors should be present.
|
|
diags = env.driveDiagnostics();
|
|
expect(diags.length).toBe(1);
|
|
expect(diags[0].messageText)
|
|
.toContain(`Type '"gamma"' is not assignable to type 'keyof Keys'.`);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
function setupFooBarProgram(env: NgtscTestEnvironment) {
|
|
env.write('foo_component.ts', `
|
|
import {Component} from '@angular/core';
|
|
import {fooSelector} from './foo_selector';
|
|
|
|
@Component({
|
|
selector: fooSelector,
|
|
template: '{{ 1 | foo }}'
|
|
})
|
|
export class FooCmp {}
|
|
`);
|
|
env.write('foo_pipe.ts', `
|
|
import {Pipe} from '@angular/core';
|
|
|
|
@Pipe({name: 'foo'})
|
|
export class FooPipe {}
|
|
`);
|
|
env.write('foo_module.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {FooCmp} from './foo_component';
|
|
import {FooPipe} from './foo_pipe';
|
|
import {BarModule} from './bar_module';
|
|
@NgModule({
|
|
declarations: [FooCmp, FooPipe],
|
|
imports: [BarModule],
|
|
})
|
|
export class FooModule {}
|
|
`);
|
|
env.write('bar_component.ts', `
|
|
import {Component} from '@angular/core';
|
|
|
|
@Component({selector: 'bar', templateUrl: './bar_component.html'})
|
|
export class BarCmp {}
|
|
`);
|
|
env.write('bar_component.html', '<div bar></div>');
|
|
env.write('bar_directive.ts', `
|
|
import {Directive} from '@angular/core';
|
|
|
|
@Directive({selector: '[bar]'})
|
|
export class BarDir {}
|
|
`);
|
|
env.write('bar_pipe.ts', `
|
|
import {Pipe} from '@angular/core';
|
|
|
|
@Pipe({name: 'foo'})
|
|
export class BarPipe {}
|
|
`);
|
|
env.write('bar_module.ts', `
|
|
import {NgModule} from '@angular/core';
|
|
import {BarCmp} from './bar_component';
|
|
import {BarDir} from './bar_directive';
|
|
import {BarPipe} from './bar_pipe';
|
|
@NgModule({
|
|
declarations: [BarCmp, BarDir, BarPipe],
|
|
exports: [BarCmp, BarPipe],
|
|
})
|
|
export class BarModule {}
|
|
`);
|
|
env.write('foo_selector.d.ts', `
|
|
export const fooSelector = 'foo';
|
|
`);
|
|
env.driveMain();
|
|
env.flushWrittenFileTracking();
|
|
}
|
|
});
|