feat(ivy): generate .ngfactory stubs if requested (#25176)

Existing bootstrap code in the wild depends on the existence of
.ngfactory files, which Ivy does not need. This commit adds the
capability in ngtsc to generate .ngfactory files which bridge
existing bootstrap code with Ivy.

This is an initial step. Remaining work includes complying with
the compiler option to specify a generated file directory, as well
as presumably testing in g3.

PR Close #25176
This commit is contained in:
Alex Rickabaugh 2018-07-27 22:57:44 -07:00 committed by Kara Erickson
parent 728d98d3a9
commit 0822dc70f2
13 changed files with 338 additions and 10 deletions

View File

@ -26,6 +26,7 @@ ts_library(
deps = [
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/annotations",
"//packages/compiler-cli/src/ngtsc/factories",
"//packages/compiler-cli/src/ngtsc/metadata",
"//packages/compiler-cli/src/ngtsc/transform",
],

View File

@ -116,6 +116,7 @@ export class NgModuleDecoratorHandler implements DecoratorHandler<NgModuleAnalys
analysis: {
ngModuleDef, ngInjectorDef,
},
factorySymbolName: node.name !== undefined ? node.name.text : undefined,
};
}

View File

@ -0,0 +1,18 @@
package(default_visibility = ["//visibility:public"])
load("//tools:defaults.bzl", "ts_library")
ts_library(
name = "factories",
srcs = glob([
"index.ts",
"src/**/*.ts",
]),
module_name = "@angular/compiler-cli/src/ngtsc/factories",
deps = [
"//packages/compiler",
"//packages/compiler-cli/src/ngtsc/host",
"//packages/compiler-cli/src/ngtsc/metadata",
"//packages/compiler-cli/src/ngtsc/util",
],
)

View File

@ -0,0 +1,11 @@
Deals with the creation of generated factory files.
Generated factory files create a catch-22 in ngtsc. Their contents depends on static analysis of the current program, yet they're also importable from the current program. This importability gives rise to the requirement that the contents of the generated file must be known before program creation, so that imports of it are valid. However, until the program is created, the analysis to determine the contents of the generated file cannot take place.
ngc used to get away with this because the analysis phase did not depend on program creation but on the metadata collection / global analysis process.
ngtsc is forced to take a different approach. A lightweight analysis pipeline which does not rely on the ts.TypeChecker (and thus can run before the program is created) is used to estimate the contents of a generated file, in a way that allows the program to be created. A transformer then operates on this estimated file during emit and replaces the estimated contents with accurate information.
It is important that this estimate be an overestimate, as type-checking will always be run against the estimated file, and must succeed in every case where it would have succeeded with accurate info.
This directory contains the utilities for generating, updating, and incorporating these factory files into a ts.Program.

View File

@ -0,0 +1,11 @@
/**
* @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
*/
export {FactoryGenerator} from './src/generator';
export {GeneratedFactoryHostWrapper} from './src/host';
export {FactoryInfo, generatedFactoryTransform} from './src/transform';

View File

@ -0,0 +1,63 @@
/**
* @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 * as path from 'path';
import * as ts from 'typescript';
const TS_DTS_SUFFIX = /(\.d)?\.ts$/;
/**
* Generates ts.SourceFiles which contain variable declarations for NgFactories for every exported
* class of an input ts.SourceFile.
*/
export class FactoryGenerator {
factoryFor(original: ts.SourceFile, genFilePath: string): ts.SourceFile {
const relativePathToSource =
'./' + path.posix.basename(original.fileName).replace(TS_DTS_SUFFIX, '');
// Collect a list of classes that need to have factory types emitted for them.
const symbolNames = original
.statements
// Pick out top level class declarations...
.filter(ts.isClassDeclaration)
// which are named, exported, and have decorators.
.filter(
decl => isExported(decl) && decl.decorators !== undefined &&
decl.name !== undefined)
// Grab the symbol name.
.map(decl => decl.name !.text);
// For each symbol name, generate a constant export of the corresponding NgFactory.
// This will encompass a lot of symbols which don't need factories, but that's okay
// because it won't miss any that do.
const varLines = symbolNames.map(
name => `export const ${name}NgFactory = new i0.ɵNgModuleFactory(${name});`);
const sourceText = [
// This might be incorrect if the current package being compiled is Angular core, but it's
// okay to leave in at type checking time. TypeScript can handle this reference via its path
// mapping, but downstream bundlers can't. If the current package is core itself, this will be
// replaced in the factory transformer before emit.
`import * as i0 from '@angular/core';`,
`import {${symbolNames.join(', ')}} from '${relativePathToSource}';`,
...varLines,
].join('\n');
return ts.createSourceFile(
genFilePath, sourceText, original.languageVersion, true, ts.ScriptKind.TS);
}
computeFactoryFileMap(files: ReadonlyArray<string>): Map<string, string> {
const map = new Map<string, string>();
files.filter(sourceFile => !sourceFile.endsWith('.d.ts'))
.forEach(sourceFile => map.set(sourceFile.replace(/\.ts$/, '.ngfactory.ts'), sourceFile));
return map;
}
}
function isExported(decl: ts.Declaration): boolean {
return decl.modifiers !== undefined &&
decl.modifiers.some(mod => mod.kind == ts.SyntaxKind.ExportKeyword);
}

View File

@ -0,0 +1,68 @@
/**
* @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 * as path from 'path';
import * as ts from 'typescript';
import {FactoryGenerator} from './generator';
/**
* A wrapper around a `ts.CompilerHost` which supports generated files.
*/
export class GeneratedFactoryHostWrapper implements ts.CompilerHost {
constructor(
private delegate: ts.CompilerHost, private generator: FactoryGenerator,
private factoryToSourceMap: Map<string, string>) {}
getSourceFile(
fileName: string, languageVersion: ts.ScriptTarget,
onError?: ((message: string) => void)|undefined,
shouldCreateNewSourceFile?: boolean|undefined): ts.SourceFile|undefined {
const canonical = this.getCanonicalFileName(fileName);
if (this.factoryToSourceMap.has(canonical)) {
const sourceFileName = this.getCanonicalFileName(this.factoryToSourceMap.get(canonical) !);
const sourceFile = this.delegate.getSourceFile(
sourceFileName, languageVersion, onError, shouldCreateNewSourceFile);
if (sourceFile === undefined) {
return undefined;
}
return this.generator.factoryFor(sourceFile, fileName);
}
return this.delegate.getSourceFile(
fileName, languageVersion, onError, shouldCreateNewSourceFile);
}
getDefaultLibFileName(options: ts.CompilerOptions): string {
return this.delegate.getDefaultLibFileName(options);
}
writeFile(
fileName: string, data: string, writeByteOrderMark: boolean,
onError: ((message: string) => void)|undefined,
sourceFiles: ReadonlyArray<ts.SourceFile>): void {
return this.delegate.writeFile(fileName, data, writeByteOrderMark, onError, sourceFiles);
}
getCurrentDirectory(): string { return this.delegate.getCurrentDirectory(); }
getDirectories(path: string): string[] { return this.delegate.getDirectories(path); }
getCanonicalFileName(fileName: string): string {
return this.delegate.getCanonicalFileName(fileName);
}
useCaseSensitiveFileNames(): boolean { return this.delegate.useCaseSensitiveFileNames(); }
getNewLine(): string { return this.delegate.getNewLine(); }
fileExists(fileName: string): boolean {
return this.factoryToSourceMap.has(fileName) || this.delegate.fileExists(fileName);
}
readFile(fileName: string): string|undefined { return this.delegate.readFile(fileName); }
}

View File

@ -0,0 +1,78 @@
/**
* @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 * as ts from 'typescript';
import {relativePathBetween} from '../../util/src/path';
const STRIP_NG_FACTORY = /(.*)NgFactory$/;
export interface FactoryInfo {
sourceFilePath: string;
moduleSymbolNames: Set<string>;
}
export function generatedFactoryTransform(
factoryMap: Map<string, FactoryInfo>,
coreImportsFrom: ts.SourceFile | null): ts.TransformerFactory<ts.SourceFile> {
return (context: ts.TransformationContext): ts.Transformer<ts.SourceFile> => {
return (file: ts.SourceFile): ts.SourceFile => {
return transformFactorySourceFile(factoryMap, context, coreImportsFrom, file);
};
};
}
function transformFactorySourceFile(
factoryMap: Map<string, FactoryInfo>, context: ts.TransformationContext,
coreImportsFrom: ts.SourceFile | null, file: ts.SourceFile): ts.SourceFile {
// If this is not a generated file, it won't have factory info associated with it.
if (!factoryMap.has(file.fileName)) {
// Don't transform non-generated code.
return file;
}
const {moduleSymbolNames, sourceFilePath} = factoryMap.get(file.fileName) !;
const clone = ts.getMutableClone(file);
const transformedStatements = file.statements.map(stmt => {
if (coreImportsFrom !== null && ts.isImportDeclaration(stmt) &&
ts.isStringLiteral(stmt.moduleSpecifier) && stmt.moduleSpecifier.text === '@angular/core') {
const path = relativePathBetween(sourceFilePath, coreImportsFrom.fileName);
if (path !== null) {
return ts.updateImportDeclaration(
stmt, stmt.decorators, stmt.modifiers, stmt.importClause, ts.createStringLiteral(path));
} else {
return ts.createNotEmittedStatement(stmt);
}
} else if (ts.isVariableStatement(stmt) && stmt.declarationList.declarations.length === 1) {
const decl = stmt.declarationList.declarations[0];
if (ts.isIdentifier(decl.name)) {
const match = STRIP_NG_FACTORY.exec(decl.name.text);
if (match === null || !moduleSymbolNames.has(match[1])) {
// Remove the given factory as it wasn't actually for an NgModule.
return ts.createNotEmittedStatement(stmt);
}
}
return stmt;
} else {
return stmt;
}
});
if (!transformedStatements.some(ts.isVariableStatement)) {
// If the resulting file has no factories, include an empty export to
// satisfy closure compiler.
transformedStatements.push(ts.createVariableStatement(
[ts.createModifier(ts.SyntaxKind.ExportKeyword)],
ts.createVariableDeclarationList(
[ts.createVariableDeclaration('ɵNonEmptyModule', undefined, ts.createTrue())],
ts.NodeFlags.Const)));
}
clone.statements = ts.createNodeArray(transformedStatements);
return clone;
}

View File

@ -13,6 +13,7 @@ import * as ts from 'typescript';
import * as api from '../transformers/api';
import {ComponentDecoratorHandler, DirectiveDecoratorHandler, InjectableDecoratorHandler, NgModuleDecoratorHandler, PipeDecoratorHandler, ResourceLoader, SelectorScopeRegistry} from './annotations';
import {FactoryGenerator, FactoryInfo, GeneratedFactoryHostWrapper, generatedFactoryTransform} from './factories';
import {TypeScriptReflectionHost} from './metadata';
import {FileResourceLoader, HostResourceLoader} from './resource_loader';
import {IvyCompilation, ivyTransformFactory} from './transform';
@ -21,20 +22,39 @@ export class NgtscProgram implements api.Program {
private tsProgram: ts.Program;
private resourceLoader: ResourceLoader;
private compilation: IvyCompilation|undefined = undefined;
private factoryToSourceInfo: Map<string, FactoryInfo>|null = null;
private sourceToFactorySymbols: Map<string, Set<string>>|null = null;
private host: ts.CompilerHost;
private _coreImportsFrom: ts.SourceFile|null|undefined = undefined;
private _reflector: TypeScriptReflectionHost|undefined = undefined;
private _isCore: boolean|undefined = undefined;
constructor(
rootNames: ReadonlyArray<string>, private options: api.CompilerOptions,
private host: api.CompilerHost, oldProgram?: api.Program) {
host: api.CompilerHost, oldProgram?: api.Program) {
this.resourceLoader = host.readResource !== undefined ?
new HostResourceLoader(host.readResource.bind(host)) :
new FileResourceLoader();
const shouldGenerateFactories = options.allowEmptyCodegenFiles || false;
this.host = host;
let rootFiles = [...rootNames];
if (shouldGenerateFactories) {
const generator = new FactoryGenerator();
const factoryFileMap = generator.computeFactoryFileMap(rootNames);
rootFiles.push(...Array.from(factoryFileMap.keys()));
this.factoryToSourceInfo = new Map<string, FactoryInfo>();
this.sourceToFactorySymbols = new Map<string, Set<string>>();
factoryFileMap.forEach((sourceFilePath, factoryPath) => {
const moduleSymbolNames = new Set<string>();
this.sourceToFactorySymbols !.set(sourceFilePath, moduleSymbolNames);
this.factoryToSourceInfo !.set(factoryPath, {sourceFilePath, moduleSymbolNames});
});
this.host = new GeneratedFactoryHostWrapper(host, generator, factoryFileMap);
}
this.tsProgram =
ts.createProgram(rootNames, options, host, oldProgram && oldProgram.getTsProgram());
ts.createProgram(rootFiles, options, this.host, oldProgram && oldProgram.getTsProgram());
}
getTsProgram(): ts.Program { return this.tsProgram; }
@ -125,7 +145,11 @@ export class NgtscProgram implements api.Program {
this.host.writeFile(fileName, data, writeByteOrderMark, onError, sourceFiles);
};
const transforms =
[ivyTransformFactory(this.compilation !, this.reflector, this.coreImportsFrom)];
if (this.factoryToSourceInfo !== null) {
transforms.push(generatedFactoryTransform(this.factoryToSourceInfo, this.coreImportsFrom));
}
// Run the emit, including a custom transformer that will downlevel the Ivy decorators in code.
const emitResult = emitCallback({
program: this.tsProgram,
@ -133,7 +157,7 @@ export class NgtscProgram implements api.Program {
options: this.options,
emitOnlyDtsFiles: false, writeFile,
customTransformers: {
before: [ivyTransformFactory(this.compilation !, this.reflector, this.coreImportsFrom)],
before: transforms,
},
});
return emitResult;
@ -153,7 +177,8 @@ export class NgtscProgram implements api.Program {
new PipeDecoratorHandler(checker, this.reflector, scopeRegistry, this.isCore),
];
return new IvyCompilation(handlers, checker, this.reflector, this.coreImportsFrom);
return new IvyCompilation(
handlers, checker, this.reflector, this.coreImportsFrom, this.sourceToFactorySymbols);
}
private get reflector(): TypeScriptReflectionHost {

View File

@ -57,6 +57,7 @@ export interface DecoratorHandler<A> {
export interface AnalysisOutput<A> {
analysis?: A;
diagnostics?: ts.Diagnostic[];
factorySymbolName?: string;
}
/**

View File

@ -37,6 +37,10 @@ export class IvyCompilation {
*/
private analysis = new Map<ts.Declaration, EmitFieldOperation<any>>();
/**
* Tracks factory information which needs to be generated.
*/
/**
* Tracks the `DtsFileTransformer`s for each TS file that needs .d.ts transformations.
*/
@ -55,7 +59,8 @@ export class IvyCompilation {
*/
constructor(
private handlers: DecoratorHandler<any>[], private checker: ts.TypeChecker,
private reflector: ReflectionHost, private coreImportsFrom: ts.SourceFile|null) {}
private reflector: ReflectionHost, private coreImportsFrom: ts.SourceFile|null,
private sourceToFactorySymbols: Map<string, Set<string>>|null) {}
analyzeSync(sf: ts.SourceFile): void { return this.analyze(sf, false); }
@ -105,6 +110,11 @@ export class IvyCompilation {
if (analysis.diagnostics !== undefined) {
this._diagnostics.push(...analysis.diagnostics);
}
if (analysis.factorySymbolName !== undefined && this.sourceToFactorySymbols !== null &&
this.sourceToFactorySymbols.has(sf.fileName)) {
this.sourceToFactorySymbols.get(sf.fileName) !.add(analysis.factorySymbolName);
}
};
if (preanalyze && adapter.preanalyze !== undefined) {

View File

@ -49,6 +49,9 @@ export class ElementRef {}
export class Injector {}
export class TemplateRef {}
export class ViewContainerRef {}
export class ɵNgModuleFactory<T> {
constructor(public clazz: T) {}
}
export function forwardRef<T>(fn: () => T): T {
return fn();

View File

@ -58,9 +58,10 @@ describe('ngtsc behavioral tests', () => {
return fs.readFileSync(modulePath, 'utf8');
}
function writeConfig(
tsconfig: string =
'{"extends": "./tsconfig-base.json", "angularCompilerOptions": {"enableIvy": "ngtsc"}}') {
function writeConfig(extraOpts: {[key: string]: string | boolean} = {}): void {
const opts = JSON.stringify({...extraOpts, 'enableIvy': 'ngtsc'});
const tsconfig: string =
`{"extends": "./tsconfig-base.json", "angularCompilerOptions": ${opts}}`;
write('tsconfig.json', tsconfig);
}
@ -602,4 +603,41 @@ describe('ngtsc behavioral tests', () => {
const jsContents = getContents('test.js');
expect(jsContents).not.toMatch(/import \* as i[0-9] from ['"].\/test['"]/);
});
it('should generate correct factory stubs for a test module', () => {
writeConfig({'allowEmptyCodegenFiles': true});
write('test.ts', `
import {Injectable, NgModule} from '@angular/core';
@Injectable()
export class NotAModule {}
@NgModule({})
export class TestModule {}
`);
write('empty.ts', `
import {Injectable} from '@angular/core';
@Injectable()
export class NotAModule {}
`);
const exitCode = main(['-p', basePath], errorSpy);
expect(errorSpy).not.toHaveBeenCalled();
expect(exitCode).toBe(0);
const factoryContents = getContents('test.ngfactory.js');
expect(factoryContents).toContain(`import * as i0 from '@angular/core';`);
expect(factoryContents).toContain(`import { NotAModule, TestModule } from './test';`);
expect(factoryContents)
.toContain(`export var TestModuleNgFactory = new i0.ɵNgModuleFactory(TestModule);`);
expect(factoryContents).not.toContain(`NotAModuleNgFactory`);
expect(factoryContents).not.toContain('ɵNonEmptyModule');
const emptyFactory = getContents('empty.ngfactory.js');
expect(emptyFactory).toContain(`import * as i0 from '@angular/core';`);
expect(emptyFactory).toContain(`export var ɵNonEmptyModule = true;`);
});
});