When the Angular compiler is operated through the ngc binary in watch mode, changing a template in an external file would not cause the component to be recompiled if Ivy is enabled. There was a problem with how a cached compiler host was present that was unaware of the changed resources, therefore failing to trigger a recompilation of a component whenever its template changes. This commit fixes the issue by ensuring that information about modified resources is correctly available to the cached compiler host. Fixes #32869 PR Close #33551
		
			
				
	
	
		
			276 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			276 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
/**
 | 
						|
 * @license
 | 
						|
 * Copyright Google Inc. 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 {ivyEnabled} from '@angular/private/testing';
 | 
						|
import * as fs from 'fs';
 | 
						|
import * as path from 'path';
 | 
						|
import * as ts from 'typescript';
 | 
						|
 | 
						|
import * as ng from '../index';
 | 
						|
import {FileChangeEvent, performWatchCompilation} from '../src/perform_watch';
 | 
						|
 | 
						|
import {TestSupport, expectNoDiagnostics, setup} from './test_support';
 | 
						|
 | 
						|
describe('perform watch', () => {
 | 
						|
  let testSupport: TestSupport;
 | 
						|
  let outDir: string;
 | 
						|
 | 
						|
  beforeEach(() => {
 | 
						|
    testSupport = setup();
 | 
						|
    outDir = path.resolve(testSupport.basePath, 'outDir');
 | 
						|
  });
 | 
						|
 | 
						|
  function createConfig(overrideOptions: ng.CompilerOptions = {}): ng.ParsedConfiguration {
 | 
						|
    const options = testSupport.createCompilerOptions({outDir, ...overrideOptions});
 | 
						|
    return {
 | 
						|
      options,
 | 
						|
      rootNames: [path.resolve(testSupport.basePath, 'src/index.ts')],
 | 
						|
      project: path.resolve(testSupport.basePath, 'src/tsconfig.json'),
 | 
						|
      emitFlags: ng.EmitFlags.Default,
 | 
						|
      errors: []
 | 
						|
    };
 | 
						|
  }
 | 
						|
 | 
						|
  it('should compile files during the initial run', () => {
 | 
						|
    const config = createConfig();
 | 
						|
    const host = new MockWatchHost(config);
 | 
						|
 | 
						|
    testSupport.writeFiles({
 | 
						|
      'src/main.ts': createModuleAndCompSource('main'),
 | 
						|
      'src/index.ts': `export * from './main'; `,
 | 
						|
    });
 | 
						|
 | 
						|
    const watchResult = performWatchCompilation(host);
 | 
						|
    expectNoDiagnostics(config.options, watchResult.firstCompileResult);
 | 
						|
 | 
						|
    expect(fs.existsSync(path.resolve(outDir, 'src', 'main.ngfactory.js'))).toBe(true);
 | 
						|
  });
 | 
						|
 | 
						|
  it('should recompile components when its template changes', () => {
 | 
						|
    const config = createConfig({enableIvy: ivyEnabled});
 | 
						|
    const host = new MockWatchHost(config);
 | 
						|
 | 
						|
    testSupport.writeFiles({
 | 
						|
      'src/main.ts': createModuleAndCompSource('main', './main.html'),
 | 
						|
      'src/main.html': 'initial',
 | 
						|
      'src/index.ts': `export * from './main'; `,
 | 
						|
    });
 | 
						|
 | 
						|
    const watchResult = performWatchCompilation(host);
 | 
						|
    expectNoDiagnostics(config.options, watchResult.firstCompileResult);
 | 
						|
 | 
						|
    const htmlPath = path.posix.join(testSupport.basePath, 'src', 'main.html');
 | 
						|
    const genPath = ivyEnabled ? path.posix.join(outDir, 'src', 'main.js') :
 | 
						|
                                 path.posix.join(outDir, 'src', 'main.ngfactory.js');
 | 
						|
 | 
						|
    const initial = fs.readFileSync(genPath, {encoding: 'utf8'});
 | 
						|
    expect(initial).toContain('"initial"');
 | 
						|
 | 
						|
    testSupport.write(htmlPath, 'updated');
 | 
						|
    host.triggerFileChange(FileChangeEvent.Change, htmlPath);
 | 
						|
 | 
						|
    const updated = fs.readFileSync(genPath, {encoding: 'utf8'});
 | 
						|
    expect(updated).toContain('"updated"');
 | 
						|
  });
 | 
						|
 | 
						|
  it('should cache files on subsequent runs', () => {
 | 
						|
    const config = createConfig();
 | 
						|
    const host = new MockWatchHost(config);
 | 
						|
    let fileExistsSpy: jasmine.Spy;
 | 
						|
    let getSourceFileSpy: jasmine.Spy;
 | 
						|
    host.createCompilerHost = (options: ng.CompilerOptions) => {
 | 
						|
      const ngHost = ng.createCompilerHost({options});
 | 
						|
      fileExistsSpy = spyOn(ngHost, 'fileExists').and.callThrough();
 | 
						|
      getSourceFileSpy = spyOn(ngHost, 'getSourceFile').and.callThrough();
 | 
						|
      return ngHost;
 | 
						|
    };
 | 
						|
 | 
						|
    testSupport.writeFiles({
 | 
						|
      'src/main.ts': createModuleAndCompSource('main'),
 | 
						|
      'src/util.ts': `export const x = 1;`,
 | 
						|
      'src/index.ts': `
 | 
						|
        export * from './main';
 | 
						|
        export * from './util';
 | 
						|
      `,
 | 
						|
    });
 | 
						|
 | 
						|
    const mainTsPath = path.posix.join(testSupport.basePath, 'src', 'main.ts');
 | 
						|
    const utilTsPath = path.posix.join(testSupport.basePath, 'src', 'util.ts');
 | 
						|
    const mainNgFactory = path.posix.join(outDir, 'src', 'main.ngfactory.js');
 | 
						|
 | 
						|
    performWatchCompilation(host);
 | 
						|
    expect(fs.existsSync(mainNgFactory)).toBe(true);
 | 
						|
    expect(fileExistsSpy !).toHaveBeenCalledWith(mainTsPath);
 | 
						|
    expect(fileExistsSpy !).toHaveBeenCalledWith(utilTsPath);
 | 
						|
    expect(getSourceFileSpy !).toHaveBeenCalledWith(mainTsPath, ts.ScriptTarget.ES5);
 | 
						|
    expect(getSourceFileSpy !).toHaveBeenCalledWith(utilTsPath, ts.ScriptTarget.ES5);
 | 
						|
 | 
						|
    fileExistsSpy !.calls.reset();
 | 
						|
    getSourceFileSpy !.calls.reset();
 | 
						|
 | 
						|
    // trigger a single file change
 | 
						|
    // -> all other files should be cached
 | 
						|
    host.triggerFileChange(FileChangeEvent.Change, utilTsPath);
 | 
						|
    expectNoDiagnostics(config.options, host.diagnostics);
 | 
						|
 | 
						|
    expect(fileExistsSpy !).not.toHaveBeenCalledWith(mainTsPath);
 | 
						|
    expect(fileExistsSpy !).toHaveBeenCalledWith(utilTsPath);
 | 
						|
    expect(getSourceFileSpy !).not.toHaveBeenCalledWith(mainTsPath, ts.ScriptTarget.ES5);
 | 
						|
    expect(getSourceFileSpy !).toHaveBeenCalledWith(utilTsPath, ts.ScriptTarget.ES5);
 | 
						|
 | 
						|
    // trigger a folder change
 | 
						|
    // -> nothing should be cached
 | 
						|
    host.triggerFileChange(
 | 
						|
        FileChangeEvent.CreateDeleteDir, path.resolve(testSupport.basePath, 'src'));
 | 
						|
    expectNoDiagnostics(config.options, host.diagnostics);
 | 
						|
 | 
						|
    expect(fileExistsSpy !).toHaveBeenCalledWith(mainTsPath);
 | 
						|
    expect(fileExistsSpy !).toHaveBeenCalledWith(utilTsPath);
 | 
						|
    expect(getSourceFileSpy !).toHaveBeenCalledWith(mainTsPath, ts.ScriptTarget.ES5);
 | 
						|
    expect(getSourceFileSpy !).toHaveBeenCalledWith(utilTsPath, ts.ScriptTarget.ES5);
 | 
						|
  });
 | 
						|
 | 
						|
  // https://github.com/angular/angular/pull/26036
 | 
						|
  it('should handle redirected source files', () => {
 | 
						|
    const config = createConfig();
 | 
						|
    const host = new MockWatchHost(config);
 | 
						|
    host.createCompilerHost = (options: ng.CompilerOptions) => {
 | 
						|
      const ngHost = ng.createCompilerHost({options});
 | 
						|
      return ngHost;
 | 
						|
    };
 | 
						|
 | 
						|
    // 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 watch compilations, we must assure
 | 
						|
    // not to reintroduce "node_modules/b/node_modules/a/index.d.ts" as its redirected source file,
 | 
						|
    // but instead using its original file.
 | 
						|
    testSupport.writeFiles({
 | 
						|
      'node_modules/a/index.js': `export class ServiceA {}`,
 | 
						|
      'node_modules/a/index.d.ts': `export declare class ServiceA {}`,
 | 
						|
      'node_modules/a/package.json': `{"name": "a", "version": "1.0"}`,
 | 
						|
      'node_modules/b/node_modules/a/index.js': `export class ServiceA {}`,
 | 
						|
      'node_modules/b/node_modules/a/index.d.ts': `export declare class ServiceA {}`,
 | 
						|
      'node_modules/b/node_modules/a/package.json': `{"name": "a", "version": "1.0"}`,
 | 
						|
      'node_modules/b/index.js': `export {ServiceA as ServiceB} from 'a';`,
 | 
						|
      'node_modules/b/index.d.ts': `export {ServiceA as ServiceB} from 'a';`,
 | 
						|
      'src/index.ts': `
 | 
						|
        import {ServiceA} from 'a';
 | 
						|
        import {ServiceB} from 'b';
 | 
						|
      `,
 | 
						|
    });
 | 
						|
 | 
						|
    const indexTsPath = path.posix.join(testSupport.basePath, 'src', 'index.ts');
 | 
						|
 | 
						|
    performWatchCompilation(host);
 | 
						|
 | 
						|
    // Trigger a file change. This recreates the program from the old program. If redirect sources
 | 
						|
    // were introduced into the new program, this would fail due to an assertion failure in TS.
 | 
						|
    host.triggerFileChange(FileChangeEvent.Change, indexTsPath);
 | 
						|
    expectNoDiagnostics(config.options, host.diagnostics);
 | 
						|
  });
 | 
						|
 | 
						|
 | 
						|
  it('should recover from static analysis errors', () => {
 | 
						|
    const config = createConfig();
 | 
						|
    const host = new MockWatchHost(config);
 | 
						|
 | 
						|
    const okFileContent = `
 | 
						|
      import {NgModule} from '@angular/core';
 | 
						|
 | 
						|
      @NgModule()
 | 
						|
      export class MyModule {}
 | 
						|
    `;
 | 
						|
    const errorFileContent = `
 | 
						|
      import {NgModule} from '@angular/core';
 | 
						|
 | 
						|
      @NgModule((() => (1===1 ? null as any : null as any)) as any)
 | 
						|
      export class MyModule {}
 | 
						|
    `;
 | 
						|
    const indexTsPath = path.resolve(testSupport.basePath, 'src', 'index.ts');
 | 
						|
 | 
						|
    testSupport.write(indexTsPath, okFileContent);
 | 
						|
 | 
						|
    performWatchCompilation(host);
 | 
						|
    expectNoDiagnostics(config.options, host.diagnostics);
 | 
						|
 | 
						|
    // Do it multiple times as the watch mode switches internal modes.
 | 
						|
    // E.g. from regular compile to using summaries, ...
 | 
						|
    for (let i = 0; i < 3; i++) {
 | 
						|
      host.diagnostics = [];
 | 
						|
      testSupport.write(indexTsPath, okFileContent);
 | 
						|
      host.triggerFileChange(FileChangeEvent.Change, indexTsPath);
 | 
						|
      expectNoDiagnostics(config.options, host.diagnostics);
 | 
						|
 | 
						|
      host.diagnostics = [];
 | 
						|
      testSupport.write(indexTsPath, errorFileContent);
 | 
						|
      host.triggerFileChange(FileChangeEvent.Change, indexTsPath);
 | 
						|
 | 
						|
      const errDiags = host.diagnostics.filter(d => d.category === ts.DiagnosticCategory.Error);
 | 
						|
      expect(errDiags.length).toBe(1);
 | 
						|
      expect(errDiags[0].messageText).toContain('Function expressions are not supported');
 | 
						|
    }
 | 
						|
  });
 | 
						|
});
 | 
						|
 | 
						|
function createModuleAndCompSource(prefix: string, template: string = prefix + 'template') {
 | 
						|
  const templateEntry =
 | 
						|
      template.endsWith('.html') ? `templateUrl: '${template}'` : `template: \`${template}\``;
 | 
						|
  return `
 | 
						|
    import {Component, NgModule} from '@angular/core';
 | 
						|
 | 
						|
    @Component({selector: '${prefix}', ${templateEntry}})
 | 
						|
    export class ${prefix}Comp {}
 | 
						|
 | 
						|
    @NgModule({declarations: [${prefix}Comp]})
 | 
						|
    export class ${prefix}Module {}
 | 
						|
  `;
 | 
						|
}
 | 
						|
 | 
						|
class MockWatchHost {
 | 
						|
  nextTimeoutListenerId = 1;
 | 
						|
  timeoutListeners: {[id: string]: (() => void)} = {};
 | 
						|
  fileChangeListeners: Array<((event: FileChangeEvent, fileName: string) => void)|null> = [];
 | 
						|
  diagnostics: ng.Diagnostic[] = [];
 | 
						|
  constructor(public config: ng.ParsedConfiguration) {}
 | 
						|
 | 
						|
  reportDiagnostics(diags: ng.Diagnostics) { this.diagnostics.push(...(diags as ng.Diagnostic[])); }
 | 
						|
  readConfiguration() { return this.config; }
 | 
						|
  createCompilerHost(options: ng.CompilerOptions) { return ng.createCompilerHost({options}); }
 | 
						|
  createEmitCallback() { return undefined; }
 | 
						|
  onFileChange(
 | 
						|
      options: ng.CompilerOptions, listener: (event: FileChangeEvent, fileName: string) => void,
 | 
						|
      ready: () => void) {
 | 
						|
    const id = this.fileChangeListeners.length;
 | 
						|
    this.fileChangeListeners.push(listener);
 | 
						|
    ready();
 | 
						|
    return {
 | 
						|
      close: () => this.fileChangeListeners[id] = null,
 | 
						|
    };
 | 
						|
  }
 | 
						|
  setTimeout(callback: () => void): any {
 | 
						|
    const id = this.nextTimeoutListenerId++;
 | 
						|
    this.timeoutListeners[id] = callback;
 | 
						|
    return id;
 | 
						|
  }
 | 
						|
  clearTimeout(timeoutId: any): void { delete this.timeoutListeners[timeoutId]; }
 | 
						|
  flushTimeouts() {
 | 
						|
    const listeners = this.timeoutListeners;
 | 
						|
    this.timeoutListeners = {};
 | 
						|
    Object.keys(listeners).forEach(id => listeners[id]());
 | 
						|
  }
 | 
						|
  triggerFileChange(event: FileChangeEvent, fileName: string) {
 | 
						|
    this.fileChangeListeners.forEach(listener => {
 | 
						|
      if (listener) {
 | 
						|
        listener(event, fileName);
 | 
						|
      }
 | 
						|
    });
 | 
						|
    this.flushTimeouts();
 | 
						|
  }
 | 
						|
}
 |