From 0822dc70f2580a3d0e5c936ef07707dd09e7a878 Mon Sep 17 00:00:00 2001 From: Alex Rickabaugh Date: Fri, 27 Jul 2018 22:57:44 -0700 Subject: [PATCH] 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 --- packages/compiler-cli/BUILD.bazel | 1 + .../src/ngtsc/annotations/src/ng_module.ts | 1 + .../src/ngtsc/factories/BUILD.bazel | 18 +++++ .../src/ngtsc/factories/README.md | 11 +++ .../compiler-cli/src/ngtsc/factories/index.ts | 11 +++ .../src/ngtsc/factories/src/generator.ts | 63 +++++++++++++++ .../src/ngtsc/factories/src/host.ts | 68 ++++++++++++++++ .../src/ngtsc/factories/src/transform.ts | 78 +++++++++++++++++++ packages/compiler-cli/src/ngtsc/program.ts | 37 +++++++-- .../src/ngtsc/transform/src/api.ts | 1 + .../src/ngtsc/transform/src/compilation.ts | 12 ++- .../test/ngtsc/fake_core/index.ts | 3 + .../compiler-cli/test/ngtsc/ngtsc_spec.ts | 44 ++++++++++- 13 files changed, 338 insertions(+), 10 deletions(-) create mode 100644 packages/compiler-cli/src/ngtsc/factories/BUILD.bazel create mode 100644 packages/compiler-cli/src/ngtsc/factories/README.md create mode 100644 packages/compiler-cli/src/ngtsc/factories/index.ts create mode 100644 packages/compiler-cli/src/ngtsc/factories/src/generator.ts create mode 100644 packages/compiler-cli/src/ngtsc/factories/src/host.ts create mode 100644 packages/compiler-cli/src/ngtsc/factories/src/transform.ts diff --git a/packages/compiler-cli/BUILD.bazel b/packages/compiler-cli/BUILD.bazel index 09fccb0c68..484a2f6303 100644 --- a/packages/compiler-cli/BUILD.bazel +++ b/packages/compiler-cli/BUILD.bazel @@ -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", ], diff --git a/packages/compiler-cli/src/ngtsc/annotations/src/ng_module.ts b/packages/compiler-cli/src/ngtsc/annotations/src/ng_module.ts index 1421362c39..0edd97f817 100644 --- a/packages/compiler-cli/src/ngtsc/annotations/src/ng_module.ts +++ b/packages/compiler-cli/src/ngtsc/annotations/src/ng_module.ts @@ -116,6 +116,7 @@ export class NgModuleDecoratorHandler implements DecoratorHandler 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): Map { + const map = new Map(); + 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); +} diff --git a/packages/compiler-cli/src/ngtsc/factories/src/host.ts b/packages/compiler-cli/src/ngtsc/factories/src/host.ts new file mode 100644 index 0000000000..4e5b1e9b33 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/factories/src/host.ts @@ -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) {} + + 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): 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); } +} diff --git a/packages/compiler-cli/src/ngtsc/factories/src/transform.ts b/packages/compiler-cli/src/ngtsc/factories/src/transform.ts new file mode 100644 index 0000000000..acd0d9b747 --- /dev/null +++ b/packages/compiler-cli/src/ngtsc/factories/src/transform.ts @@ -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; +} + +export function generatedFactoryTransform( + factoryMap: Map, + coreImportsFrom: ts.SourceFile | null): ts.TransformerFactory { + return (context: ts.TransformationContext): ts.Transformer => { + return (file: ts.SourceFile): ts.SourceFile => { + return transformFactorySourceFile(factoryMap, context, coreImportsFrom, file); + }; + }; +} + +function transformFactorySourceFile( + factoryMap: Map, 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; +} diff --git a/packages/compiler-cli/src/ngtsc/program.ts b/packages/compiler-cli/src/ngtsc/program.ts index 48df2fbae4..401291e637 100644 --- a/packages/compiler-cli/src/ngtsc/program.ts +++ b/packages/compiler-cli/src/ngtsc/program.ts @@ -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|null = null; + private sourceToFactorySymbols: Map>|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, 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(); + this.sourceToFactorySymbols = new Map>(); + factoryFileMap.forEach((sourceFilePath, factoryPath) => { + const moduleSymbolNames = new Set(); + 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 { diff --git a/packages/compiler-cli/src/ngtsc/transform/src/api.ts b/packages/compiler-cli/src/ngtsc/transform/src/api.ts index 97e33e2cf1..1ae3cd708e 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/api.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/api.ts @@ -57,6 +57,7 @@ export interface DecoratorHandler { export interface AnalysisOutput { analysis?: A; diagnostics?: ts.Diagnostic[]; + factorySymbolName?: string; } /** diff --git a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts index 153375463b..bff07b681a 100644 --- a/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts +++ b/packages/compiler-cli/src/ngtsc/transform/src/compilation.ts @@ -37,6 +37,10 @@ export class IvyCompilation { */ private analysis = new Map>(); + /** + * 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[], private checker: ts.TypeChecker, - private reflector: ReflectionHost, private coreImportsFrom: ts.SourceFile|null) {} + private reflector: ReflectionHost, private coreImportsFrom: ts.SourceFile|null, + private sourceToFactorySymbols: Map>|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) { diff --git a/packages/compiler-cli/test/ngtsc/fake_core/index.ts b/packages/compiler-cli/test/ngtsc/fake_core/index.ts index 2f5dbf682f..7e7978e1de 100644 --- a/packages/compiler-cli/test/ngtsc/fake_core/index.ts +++ b/packages/compiler-cli/test/ngtsc/fake_core/index.ts @@ -49,6 +49,9 @@ export class ElementRef {} export class Injector {} export class TemplateRef {} export class ViewContainerRef {} +export class ɵNgModuleFactory { + constructor(public clazz: T) {} +} export function forwardRef(fn: () => T): T { return fn(); diff --git a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts index 71e51a193f..1950d63a27 100644 --- a/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts +++ b/packages/compiler-cli/test/ngtsc/ngtsc_spec.ts @@ -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;`); + }); });