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;`); + }); });