From 427f095277554582cfeee6928a4282a555996d97 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 21 Apr 2020 18:24:00 -0400 Subject: [PATCH 1/4] refactor(@ngtools/webpack): introduce Ivy Webpack compiler plugin/loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change introduces a new Ivy Webpack compiler plugin. The plugin leverages the Ivy APIs from the @angular/compiler-cli package. The plugin also simplifies and reduces the amount of code within the plugin by leveraging newer TypeScript features and capabilities. The need for the virtual filesystem has also been removed. The file replacements capability was the primary driver for the previous need for the virtual filesystem. File replacements are now implemented using a two-pronged approach. The first, for TypeScript, is to hook TypeScript module resolution and adjust the resolved modules based on the configured file replacements. This is similar in behavior to TypeScript path mapping. The second, for Webpack, is the use of the NormalModuleReplacementPlugin to facilitate bundling of the configured file replacements. An advantage to this approach is that the build system (both TypeScript and Webpack) are now aware of the replacements and can operate without augmenting multiple aspects of system as was needed previously. The plugin also introduces the use of TypeScript’s builder programs. The current primary benefit is more accurate and simplified dependency discovery. Further, they also provide for the introduction of incremental build support and incremental type checking. --- packages/ngtools/webpack/src/index.ts | 2 + .../ngtools/webpack/src/ivy/diagnostics.ts | 27 + packages/ngtools/webpack/src/ivy/host.ts | 263 ++++++++ packages/ngtools/webpack/src/ivy/index.ts | 11 + packages/ngtools/webpack/src/ivy/loader.ts | 77 +++ packages/ngtools/webpack/src/ivy/plugin.ts | 576 ++++++++++++++++++ packages/ngtools/webpack/src/ivy/symbol.ts | 16 + packages/ngtools/webpack/src/ivy/system.ts | 99 +++ .../ngtools/webpack/src/ivy/transformation.ts | 141 +++++ .../ngtools/webpack/src/resource_loader.ts | 31 +- 10 files changed, 1241 insertions(+), 2 deletions(-) create mode 100644 packages/ngtools/webpack/src/ivy/diagnostics.ts create mode 100644 packages/ngtools/webpack/src/ivy/host.ts create mode 100644 packages/ngtools/webpack/src/ivy/index.ts create mode 100644 packages/ngtools/webpack/src/ivy/loader.ts create mode 100644 packages/ngtools/webpack/src/ivy/plugin.ts create mode 100644 packages/ngtools/webpack/src/ivy/symbol.ts create mode 100644 packages/ngtools/webpack/src/ivy/system.ts create mode 100644 packages/ngtools/webpack/src/ivy/transformation.ts diff --git a/packages/ngtools/webpack/src/index.ts b/packages/ngtools/webpack/src/index.ts index bba971febb90..35de314a7db3 100644 --- a/packages/ngtools/webpack/src/index.ts +++ b/packages/ngtools/webpack/src/index.ts @@ -14,3 +14,5 @@ export const NgToolsLoader = __filename; // We shouldn't need to export this, but webpack-rollup-loader uses it. export type { VirtualFileSystemDecorator } from './virtual_file_system_decorator'; + +export * as ivy from './ivy'; diff --git a/packages/ngtools/webpack/src/ivy/diagnostics.ts b/packages/ngtools/webpack/src/ivy/diagnostics.ts new file mode 100644 index 000000000000..4ddd0b739ba9 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/diagnostics.ts @@ -0,0 +1,27 @@ +/** + * @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 { Diagnostics, formatDiagnostics } from '@angular/compiler-cli'; +import { DiagnosticCategory } from 'typescript'; +import { addError, addWarning } from '../webpack-diagnostics'; + +export type DiagnosticsReporter = (diagnostics: Diagnostics) => void; + +export function createDiagnosticsReporter( + compilation: import('webpack').compilation.Compilation, +): DiagnosticsReporter { + return (diagnostics) => { + for (const diagnostic of diagnostics) { + const text = formatDiagnostics([diagnostic]); + if (diagnostic.category === DiagnosticCategory.Error) { + addError(compilation, text); + } else { + addWarning(compilation, text); + } + } + }; +} diff --git a/packages/ngtools/webpack/src/ivy/host.ts b/packages/ngtools/webpack/src/ivy/host.ts new file mode 100644 index 000000000000..270b5d3b164c --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/host.ts @@ -0,0 +1,263 @@ +/** + * @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 { CompilerHost } from '@angular/compiler-cli'; +import { createHash } from 'crypto'; +import * as path from 'path'; +import * as ts from 'typescript'; +import { NgccProcessor } from '../ngcc_processor'; +import { WebpackResourceLoader } from '../resource_loader'; +import { forwardSlashPath } from '../utils'; + +export function augmentHostWithResources( + host: ts.CompilerHost, + resourceLoader: WebpackResourceLoader, + options: { directTemplateLoading?: boolean } = {}, +) { + const resourceHost = host as CompilerHost; + + resourceHost.readResource = function (fileName: string) { + const filePath = forwardSlashPath(fileName); + + if ( + options.directTemplateLoading && + (filePath.endsWith('.html') || filePath.endsWith('.svg')) + ) { + const content = this.readFile(filePath); + if (content === undefined) { + throw new Error('Unable to locate component resource: ' + fileName); + } + + resourceLoader.setAffectedResources(filePath, [filePath]); + + return content; + } else { + return resourceLoader.get(filePath); + } + }; + + resourceHost.resourceNameToFileName = function (resourceName: string, containingFile: string) { + return forwardSlashPath(path.join(path.dirname(containingFile), resourceName)); + }; + + resourceHost.getModifiedResourceFiles = function () { + return resourceLoader.getModifiedResourceFiles(); + }; +} + +function augmentResolveModuleNames( + host: ts.CompilerHost, + resolvedModuleModifier: ( + resolvedModule: ts.ResolvedModule | undefined, + moduleName: string, + ) => ts.ResolvedModule | undefined, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (host.resolveModuleNames) { + const baseResolveModuleNames = host.resolveModuleNames; + host.resolveModuleNames = function (moduleNames: string[], ...parameters) { + return moduleNames.map((name) => { + const result = baseResolveModuleNames.call(host, [name], ...parameters); + + return resolvedModuleModifier(result[0], name); + }); + }; + } else { + host.resolveModuleNames = function ( + moduleNames: string[], + containingFile: string, + _reusedNames: string[] | undefined, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { + return moduleNames.map((name) => { + const result = ts.resolveModuleName( + name, + containingFile, + options, + host, + moduleResolutionCache, + redirectedReference, + ).resolvedModule; + + return resolvedModuleModifier(result, name); + }); + }; + } +} + +export function augmentHostWithNgcc( + host: ts.CompilerHost, + ngcc: NgccProcessor, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + augmentResolveModuleNames( + host, + (resolvedModule, moduleName) => { + if (resolvedModule && ngcc) { + ngcc.processModule(moduleName, resolvedModule); + } + + return resolvedModule; + }, + moduleResolutionCache, + ); + + if (host.resolveTypeReferenceDirectives) { + const baseResolveTypeReferenceDirectives = host.resolveTypeReferenceDirectives; + host.resolveTypeReferenceDirectives = function (names: string[], ...parameters) { + return names.map((name) => { + const result = baseResolveTypeReferenceDirectives.call(host, [name], ...parameters); + + if (result[0] && ngcc) { + ngcc.processModule(name, result[0]); + } + + return result[0]; + }); + }; + } else { + host.resolveTypeReferenceDirectives = function ( + moduleNames: string[], + containingFile: string, + redirectedReference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { + return moduleNames.map((name) => { + const result = ts.resolveTypeReferenceDirective( + name, + containingFile, + options, + host, + redirectedReference, + ).resolvedTypeReferenceDirective; + + if (result && ngcc) { + ngcc.processModule(name, result); + } + + return result; + }); + }; + } +} + +export function augmentHostWithReplacements( + host: ts.CompilerHost, + replacements: Record, + moduleResolutionCache?: ts.ModuleResolutionCache, +): void { + if (Object.keys(replacements).length === 0) { + return; + } + + const tryReplace = (resolvedModule: ts.ResolvedModule | undefined) => { + const replacement = resolvedModule && replacements[resolvedModule.resolvedFileName]; + if (replacement) { + return { + resolvedFileName: replacement, + isExternalLibraryImport: /[\/\\]node_modules[\/\\]/.test(replacement), + }; + } else { + return resolvedModule; + } + }; + + augmentResolveModuleNames(host, tryReplace, moduleResolutionCache); +} + +export function augmentHostWithSubstitutions( + host: ts.CompilerHost, + substitutions: Record, +): void { + const regexSubstitutions: [RegExp, string][] = []; + for (const [key, value] of Object.entries(substitutions)) { + regexSubstitutions.push([new RegExp(`\\b${key}\\b`, 'g'), value]); + } + + if (regexSubstitutions.length === 0) { + return; + } + + const baseReadFile = host.readFile; + host.readFile = function (...parameters) { + let file: string | undefined = baseReadFile.call(host, ...parameters); + if (file) { + for (const entry of regexSubstitutions) { + file = file.replace(entry[0], entry[1]); + } + } + + return file; + }; +} + +export function augmentHostWithVersioning(host: ts.CompilerHost): void { + const baseGetSourceFile = host.getSourceFile; + host.getSourceFile = function (...parameters) { + const file: (ts.SourceFile & { version?: string }) | undefined = baseGetSourceFile.call( + host, + ...parameters, + ); + if (file && file.version === undefined) { + file.version = createHash('sha256').update(file.text).digest('hex'); + } + + return file; + }; +} + +export function augmentProgramWithVersioning(program: ts.Program): void { + const baseGetSourceFiles = program.getSourceFiles; + program.getSourceFiles = function (...parameters) { + const files: readonly (ts.SourceFile & { version?: string })[] = baseGetSourceFiles( + ...parameters, + ); + + for (const file of files) { + if (file.version === undefined) { + file.version = createHash('sha256').update(file.text).digest('hex'); + } + } + + return files; + }; +} + +export function augmentHostWithCaching( + host: ts.CompilerHost, + cache: Map, +): void { + const baseGetSourceFile = host.getSourceFile; + host.getSourceFile = function ( + fileName, + languageVersion, + onError, + shouldCreateNewSourceFile, + // tslint:disable-next-line: trailing-comma + ...parameters + ) { + if (!shouldCreateNewSourceFile && cache.has(fileName)) { + return cache.get(fileName); + } + + const file = baseGetSourceFile.call( + host, + fileName, + languageVersion, + onError, + true, + ...parameters, + ); + + if (file) { + cache.set(fileName, file); + } + + return file; + }; +} diff --git a/packages/ngtools/webpack/src/ivy/index.ts b/packages/ngtools/webpack/src/ivy/index.ts new file mode 100644 index 000000000000..b5e2985745ad --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/index.ts @@ -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 { angularWebpackLoader as default } from './loader'; +export { AngularPluginOptions, AngularWebpackPlugin } from './plugin'; + +export const AngularWebpackLoaderPath = __filename; diff --git a/packages/ngtools/webpack/src/ivy/loader.ts b/packages/ngtools/webpack/src/ivy/loader.ts new file mode 100644 index 000000000000..11a09a4dbbf0 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/loader.ts @@ -0,0 +1,77 @@ +/** + * @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 { AngularPluginSymbol, FileEmitter } from './symbol'; + +export function angularWebpackLoader( + this: import('webpack').loader.LoaderContext, + content: string, + // Source map types are broken in the webpack type definitions + // tslint:disable-next-line: no-any + map: any, +) { + if (this.loaderIndex !== this.loaders.length - 1) { + this.emitWarning('The Angular Webpack loader does not support chaining prior to the loader.'); + } + + const callback = this.async(); + if (!callback) { + throw new Error('Invalid webpack version'); + } + + const emitFile = this._compilation[AngularPluginSymbol] as FileEmitter; + if (typeof emitFile !== 'function') { + if (this.resourcePath.endsWith('.js')) { + // Passthrough for JS files when no plugin is used + this.callback(undefined, content, map); + + return; + } + + callback(new Error('The Angular Webpack loader requires the AngularWebpackPlugin.')); + + return; + } + + emitFile(this.resourcePath) + .then((result) => { + if (!result) { + if (this.resourcePath.endsWith('.js')) { + // Return original content for JS files if not compiled by TypeScript ("allowJs") + this.callback(undefined, content, map); + } else { + // File is not part of the compilation + const message = + `${this.resourcePath} is missing from the TypeScript compilation. ` + + `Please make sure it is in your tsconfig via the 'files' or 'include' property.`; + callback(new Error(message)); + } + + return; + } + + result.dependencies.forEach((dependency) => this.addDependency(dependency)); + + let resultContent = result.content || ''; + let resultMap; + if (result.map) { + resultContent = resultContent.replace(/^\/\/# sourceMappingURL=[^\r\n]*/gm, ''); + resultMap = JSON.parse(result.map); + resultMap.sources = resultMap.sources.map((source: string) => + path.join(path.dirname(this.resourcePath), source), + ); + } + + callback(undefined, resultContent, resultMap); + }) + .catch((err) => { + callback(err); + }); +} + +export { angularWebpackLoader as default }; diff --git a/packages/ngtools/webpack/src/ivy/plugin.ts b/packages/ngtools/webpack/src/ivy/plugin.ts new file mode 100644 index 000000000000..b7116e7988c1 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/plugin.ts @@ -0,0 +1,576 @@ +/** + * @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 { CompilerHost, CompilerOptions, readConfiguration } from '@angular/compiler-cli'; +import { NgtscProgram } from '@angular/compiler-cli/src/ngtsc/program'; +import * as path from 'path'; +import * as ts from 'typescript'; +import { + Compiler, + ContextReplacementPlugin, + NormalModuleReplacementPlugin, + compilation, +} from 'webpack'; +import { NgccProcessor } from '../ngcc_processor'; +import { TypeScriptPathsPlugin } from '../paths-plugin'; +import { WebpackResourceLoader } from '../resource_loader'; +import { forwardSlashPath } from '../utils'; +import { addError, addWarning } from '../webpack-diagnostics'; +import { mergeResolverMainFields } from '../webpack-version'; +import { DiagnosticsReporter, createDiagnosticsReporter } from './diagnostics'; +import { + augmentHostWithCaching, + augmentHostWithNgcc, + augmentHostWithReplacements, + augmentHostWithResources, + augmentHostWithSubstitutions, + augmentProgramWithVersioning, +} from './host'; +import { AngularPluginSymbol, FileEmitter } from './symbol'; +import { createWebpackSystem } from './system'; +import { createAotTransformers, createJitTransformers, mergeTransformers } from './transformation'; + +export interface AngularPluginOptions { + tsconfig: string; + compilerOptions?: CompilerOptions; + fileReplacements: Record; + substitutions: Record; + directTemplateLoading: boolean; + emitClassMetadata: boolean; + emitNgModuleScope: boolean; + suppressZoneJsIncompatibilityWarning: boolean; +} + +// Add support for missing properties in Webpack types as well as the loader's file emitter +interface WebpackCompilation extends compilation.Compilation { + compilationDependencies: Set; + rebuildModule(module: compilation.Module, callback: () => void): void; + [AngularPluginSymbol]: FileEmitter; +} + +function initializeNgccProcessor( + compiler: Compiler, + tsconfig: string, +): { processor: NgccProcessor; errors: string[]; warnings: string[] } { + const { inputFileSystem, options: webpackOptions } = compiler; + const mainFields = ([] as string[]).concat(...(webpackOptions.resolve?.mainFields || [])); + + const fileWatchPurger = (path: string) => { + if (inputFileSystem.purge) { + // Webpack typings do not contain the string parameter overload for purge + (inputFileSystem as { purge(path: string): void }).purge(path); + } + }; + + const errors: string[] = []; + const warnings: string[] = []; + const processor = new NgccProcessor( + mainFields, + fileWatchPurger, + warnings, + errors, + compiler.context, + tsconfig, + ); + + return { processor, errors, warnings }; +} + +const PLUGIN_NAME = 'angular-compiler'; + +export class AngularWebpackPlugin { + private readonly pluginOptions: AngularPluginOptions; + private watchMode?: boolean; + private ngtscNextProgram?: NgtscProgram; + private builder?: ts.EmitAndSemanticDiagnosticsBuilderProgram; + private sourceFileCache?: Map; + private buildTimestamp!: number; + private readonly lazyRouteMap: Record = {}; + private readonly requiredFilesToEmit = new Set(); + + constructor(options: Partial = {}) { + this.pluginOptions = { + emitClassMetadata: false, + emitNgModuleScope: false, + fileReplacements: {}, + substitutions: {}, + directTemplateLoading: true, + tsconfig: 'tsconfig.json', + suppressZoneJsIncompatibilityWarning: false, + ...options, + }; + } + + get options(): AngularPluginOptions { + return this.pluginOptions; + } + + apply(compiler: Compiler & { watchMode?: boolean }): void { + // Setup file replacements with webpack + for (const [key, value] of Object.entries(this.pluginOptions.fileReplacements)) { + new NormalModuleReplacementPlugin( + new RegExp('^' + key.replace(/[.*+\-?^${}()|[\]\\]/g, '\\$&') + '$'), + value, + ).apply(compiler); + } + + // Mimic VE plugin's systemjs module loader resource location for consistency + new ContextReplacementPlugin( + /\@angular[\\\/]core[\\\/]/, + path.join(compiler.context, '$$_lazy_route_resource'), + this.lazyRouteMap, + ).apply(compiler); + + // Set resolver options + const pathsPlugin = new TypeScriptPathsPlugin(); + compiler.hooks.afterResolvers.tap('angular-compiler', (compiler) => { + // 'resolverFactory' is not present in the Webpack typings + // tslint:disable-next-line: no-any + const resolverFactoryHooks = (compiler as any).resolverFactory.hooks; + + // When Ivy is enabled we need to add the fields added by NGCC + // to take precedence over the provided mainFields. + // NGCC adds fields in package.json suffixed with '_ivy_ngcc' + // Example: module -> module__ivy_ngcc + resolverFactoryHooks.resolveOptions + .for('normal') + .tap(PLUGIN_NAME, (resolveOptions: { mainFields: string[] }) => { + const originalMainFields = resolveOptions.mainFields; + const ivyMainFields = originalMainFields.map((f) => `${f}_ivy_ngcc`); + + return mergeResolverMainFields(resolveOptions, originalMainFields, ivyMainFields); + }); + + resolverFactoryHooks.resolver.for('normal').tap(PLUGIN_NAME, (resolver: {}) => { + pathsPlugin.apply(resolver); + }); + }); + + let ngccProcessor: NgccProcessor | undefined; + const resourceLoader = new WebpackResourceLoader(); + let previousUnused: Set | undefined; + compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (thisCompilation) => { + const compilation = thisCompilation as WebpackCompilation; + + // Store watch mode; assume true if not present (webpack < 4.23.0) + this.watchMode = compiler.watchMode ?? true; + + // Initialize and process eager ngcc if not already setup + if (!ngccProcessor) { + const { processor, errors, warnings } = initializeNgccProcessor( + compiler, + this.pluginOptions.tsconfig, + ); + + processor.process(); + warnings.forEach((warning) => addWarning(compilation, warning)); + errors.forEach((error) => addError(compilation, error)); + + ngccProcessor = processor; + } + + // Setup and read TypeScript and Angular compiler configuration + const { compilerOptions, rootNames, errors } = this.loadConfiguration(compilation); + + // Create diagnostics reporter and report configuration file errors + const diagnosticsReporter = createDiagnosticsReporter(compilation); + diagnosticsReporter(errors); + + // Update TypeScript path mapping plugin with new configuration + pathsPlugin.update(compilerOptions); + + // Create a Webpack-based TypeScript compiler host + const system = createWebpackSystem( + compiler.inputFileSystem, + forwardSlashPath(compiler.context), + ); + const host = ts.createIncrementalCompilerHost(compilerOptions, system); + + // Setup source file caching and reuse cache from previous compilation if present + let cache = this.sourceFileCache; + if (cache) { + // Invalidate existing cache based on compilation file timestamps + for (const [file, time] of compilation.fileTimestamps) { + if (this.buildTimestamp < time) { + cache.delete(forwardSlashPath(file)); + } + } + } else { + // Initialize a new cache + cache = new Map(); + // Only store cache if in watch mode + if (this.watchMode) { + this.sourceFileCache = cache; + } + } + this.buildTimestamp = Date.now(); + augmentHostWithCaching(host, cache); + + const moduleResolutionCache = ts.createModuleResolutionCache( + host.getCurrentDirectory(), + host.getCanonicalFileName.bind(host), + compilerOptions, + ); + + // Setup on demand ngcc + augmentHostWithNgcc(host, ngccProcessor, moduleResolutionCache); + + // Setup resource loading + resourceLoader.update(compilation); + augmentHostWithResources(host, resourceLoader, { + directTemplateLoading: this.pluginOptions.directTemplateLoading, + }); + + // Setup source file adjustment options + augmentHostWithReplacements(host, this.pluginOptions.fileReplacements, moduleResolutionCache); + augmentHostWithSubstitutions(host, this.pluginOptions.substitutions); + + // Create the file emitter used by the webpack loader + const { fileEmitter, builder, internalFiles } = compilerOptions.skipTemplateCodegen + ? this.updateJitProgram(compilerOptions, rootNames, host, diagnosticsReporter) + : this.updateAotProgram( + compilerOptions, + rootNames, + host, + diagnosticsReporter, + resourceLoader, + ); + + const allProgramFiles = builder + .getSourceFiles() + .filter((sourceFile) => !internalFiles?.has(sourceFile)); + + // Ensure all program files are considered part of the compilation and will be watched + allProgramFiles.forEach((sourceFile) => + compilation.compilationDependencies.add(sourceFile.fileName), + ); + + compilation.hooks.finishModules.tapPromise(PLUGIN_NAME, async (modules) => { + // Rebuild any remaining AOT required modules + const rebuild = (filename: string) => new Promise((resolve) => { + // tslint:disable-next-line: no-any + const module = modules.find((element) => (element as any).resource === filename); + if (!module) { + resolve(); + } else { + compilation.rebuildModule(module, resolve); + } + }); + + for (const requiredFile of this.requiredFilesToEmit) { + await rebuild(requiredFile); + } + this.requiredFilesToEmit.clear(); + + // Analyze program for unused files + if (compilation.errors.length > 0) { + return; + } + + const currentUnused = new Set( + allProgramFiles + .filter((sourceFile) => !sourceFile.isDeclarationFile) + .map((sourceFile) => sourceFile.fileName), + ); + modules.forEach((module) => { + const { resource } = module as { resource?: string }; + const sourceFile = resource && builder.getSourceFile(forwardSlashPath(resource)); + if (!sourceFile) { + return; + } + + builder.getAllDependencies(sourceFile).forEach((dep) => currentUnused.delete(dep)); + }); + for (const unused of currentUnused) { + if (previousUnused && previousUnused.has(unused)) { + continue; + } + addWarning( + compilation, + `${unused} is part of the TypeScript compilation but it's unused.\n` + + `Add only entry points to the 'files' or 'include' properties in your tsconfig.`, + ); + } + previousUnused = currentUnused; + }); + + // Store file emitter for loader usage + compilation[AngularPluginSymbol] = fileEmitter; + }); + } + + private loadConfiguration(compilation: WebpackCompilation) { + const { options: compilerOptions, rootNames, errors } = readConfiguration( + this.pluginOptions.tsconfig, + this.pluginOptions.compilerOptions, + ); + compilerOptions.enableIvy = true; + compilerOptions.noEmitOnError = false; + compilerOptions.suppressOutputPathCheck = true; + compilerOptions.outDir = undefined; + compilerOptions.inlineSources = compilerOptions.sourceMap; + compilerOptions.inlineSourceMap = false; + compilerOptions.mapRoot = undefined; + compilerOptions.sourceRoot = undefined; + compilerOptions.allowEmptyCodegenFiles = false; + compilerOptions.annotationsAs = 'decorators'; + compilerOptions.enableResourceInlining = false; + + if ( + !this.pluginOptions.suppressZoneJsIncompatibilityWarning && + compilerOptions.target && + compilerOptions.target >= ts.ScriptTarget.ES2017 + ) { + addWarning( + compilation, + 'Zone.js does not support native async/await in ES2017+.\n' + + 'These blocks are not intercepted by zone.js and will not triggering change detection.\n' + + 'See: https://github.com/angular/zone.js/pull/1140 for more information.', + ); + } + + return { compilerOptions, rootNames, errors }; + } + + private updateAotProgram( + compilerOptions: CompilerOptions, + rootNames: string[], + host: CompilerHost, + diagnosticsReporter: DiagnosticsReporter, + resourceLoader: WebpackResourceLoader, + ) { + // Create the Angular specific program that contains the Angular compiler + const angularProgram = new NgtscProgram( + rootNames, + compilerOptions, + host, + this.ngtscNextProgram, + ); + const angularCompiler = angularProgram.compiler; + + // The `ignoreForEmit` return value can be safely ignored when emitting. Only files + // that will be bundled (requested by Webpack) will be emitted. Combined with TypeScript's + // eliding of type only imports, this will cause type only files to be automatically ignored. + // Internal Angular type check files are also not resolvable by the bundler. Even if they + // were somehow errantly imported, the bundler would error before an emit was attempted. + // Diagnostics are still collected for all files which requires using `ignoreForDiagnostics`. + const { ignoreForDiagnostics, ignoreForEmit } = angularCompiler; + + // SourceFile versions are required for builder programs. + // The wrapped host inside NgtscProgram adds additional files that will not have versions. + const typeScriptProgram = angularProgram.getTsProgram(); + augmentProgramWithVersioning(typeScriptProgram); + + const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + typeScriptProgram, + host, + this.builder, + ); + + // Save for next rebuild + if (this.watchMode) { + this.builder = builder; + this.ngtscNextProgram = angularProgram; + } + + // Update semantic diagnostics cache + while (true) { + const result = builder.getSemanticDiagnosticsOfNextAffectedFile(undefined, (sourceFile) => + ignoreForDiagnostics.has(sourceFile), + ); + if (!result) { + break; + } + } + + // Collect non-semantic diagnostics + const diagnostics = [ + ...angularCompiler.getOptionDiagnostics(), + ...builder.getOptionsDiagnostics(), + ...builder.getGlobalDiagnostics(), + ...builder.getSyntacticDiagnostics(), + ]; + diagnosticsReporter(diagnostics); + + // Collect semantic diagnostics + for (const sourceFile of builder.getSourceFiles()) { + if (!ignoreForDiagnostics.has(sourceFile)) { + diagnosticsReporter(builder.getSemanticDiagnostics(sourceFile)); + } + } + + const transformers = createAotTransformers(builder, this.pluginOptions); + + const getDependencies = (sourceFile: ts.SourceFile) => { + const dependencies = []; + for (const resourceDependency of angularCompiler.getResourceDependencies(sourceFile)) { + const resourcePath = forwardSlashPath(resourceDependency); + dependencies.push( + resourcePath, + // Retrieve all dependencies of the resource (stylesheet imports, etc.) + ...resourceLoader.getResourceDependencies(resourcePath), + ); + } + + return dependencies; + }; + + // Required to support asynchronous resource loading + // Must be done before creating transformers or getting template diagnostics + const pendingAnalysis = angularCompiler.analyzeAsync().then(() => { + this.requiredFilesToEmit.clear(); + + for (const sourceFile of builder.getSourceFiles()) { + // Collect Angular template diagnostics + if (!ignoreForDiagnostics.has(sourceFile)) { + diagnosticsReporter(angularCompiler.getDiagnostics(sourceFile)); + } + + // Collect sources that are required to be emitted + if ( + !sourceFile.isDeclarationFile && + !ignoreForEmit.has(sourceFile) && + !angularCompiler.incrementalDriver.safeToSkipEmit(sourceFile) + ) { + this.requiredFilesToEmit.add(sourceFile.fileName); + } + } + + // NOTE: This can be removed once support for the deprecated lazy route string format is removed + for (const lazyRoute of angularCompiler.listLazyRoutes()) { + const [routeKey] = lazyRoute.route.split('#'); + const routePath = forwardSlashPath(lazyRoute.referencedModule.filePath); + this.lazyRouteMap[routeKey] = routePath; + } + + return this.createFileEmitter( + builder, + mergeTransformers(angularCompiler.prepareEmit().transformers, transformers), + getDependencies, + (sourceFile) => { + this.requiredFilesToEmit.delete(sourceFile.fileName); + angularCompiler.incrementalDriver.recordSuccessfulEmit(sourceFile); + }, + ); + }); + const analyzingFileEmitter: FileEmitter = async (file) => { + const innerFileEmitter = await pendingAnalysis; + + return innerFileEmitter(file); + }; + + return { + fileEmitter: analyzingFileEmitter, + builder, + internalFiles: ignoreForEmit, + }; + } + + private updateJitProgram( + compilerOptions: CompilerOptions, + rootNames: readonly string[], + host: CompilerHost, + diagnosticsReporter: DiagnosticsReporter, + ) { + const builder = ts.createEmitAndSemanticDiagnosticsBuilderProgram( + rootNames, + compilerOptions, + host, + this.builder, + ); + + // Save for next rebuild + if (this.watchMode) { + this.builder = builder; + } + + const diagnostics = [ + ...builder.getOptionsDiagnostics(), + ...builder.getGlobalDiagnostics(), + ...builder.getSyntacticDiagnostics(), + // Gather incremental semantic diagnostics + ...builder.getSemanticDiagnostics(), + ]; + diagnosticsReporter(diagnostics); + + const transformers = createJitTransformers(builder, this.pluginOptions); + + // Required to support asynchronous resource loading + // Must be done before listing lazy routes + // NOTE: This can be removed once support for the deprecated lazy route string format is removed + const angularProgram = new NgtscProgram( + rootNames, + compilerOptions, + host, + this.ngtscNextProgram, + ); + const angularCompiler = angularProgram.compiler; + const pendingAnalysis = angularCompiler.analyzeAsync().then(() => { + for (const lazyRoute of angularCompiler.listLazyRoutes()) { + const [routeKey] = lazyRoute.route.split('#'); + const routePath = forwardSlashPath(lazyRoute.referencedModule.filePath); + this.lazyRouteMap[routeKey] = routePath; + } + + return this.createFileEmitter(builder, transformers, () => []); + }); + const analyzingFileEmitter: FileEmitter = async (file) => { + const innerFileEmitter = await pendingAnalysis; + + return innerFileEmitter(file); + }; + + if (this.watchMode) { + this.ngtscNextProgram = angularProgram; + } + + return { + fileEmitter: analyzingFileEmitter, + builder, + internalFiles: undefined, + }; + } + + private createFileEmitter( + program: ts.BuilderProgram, + transformers: ts.CustomTransformers = {}, + getExtraDependencies: (sourceFile: ts.SourceFile) => Iterable, + onAfterEmit?: (sourceFile: ts.SourceFile) => void, + ): FileEmitter { + return async (file: string) => { + const sourceFile = program.getSourceFile(forwardSlashPath(file)); + if (!sourceFile) { + return undefined; + } + + let content: string | undefined = undefined; + let map: string | undefined = undefined; + program.emit( + sourceFile, + (filename, data) => { + if (filename.endsWith('.map')) { + map = data; + } else if (filename.endsWith('.js')) { + content = data; + } + }, + undefined, + undefined, + transformers, + ); + + onAfterEmit?.(sourceFile); + + const dependencies = [ + ...program.getAllDependencies(sourceFile), + ...getExtraDependencies(sourceFile), + ]; + + return { content, map, dependencies }; + }; + } +} diff --git a/packages/ngtools/webpack/src/ivy/symbol.ts b/packages/ngtools/webpack/src/ivy/symbol.ts new file mode 100644 index 000000000000..b3dd0faafe68 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/symbol.ts @@ -0,0 +1,16 @@ +/** + * @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 const AngularPluginSymbol = Symbol.for('@angular-devkit/build-angular[angular-compiler]'); + +export interface EmitFileResult { + content?: string; + map?: string; + dependencies: readonly string[]; +} + +export type FileEmitter = (file: string) => Promise; diff --git a/packages/ngtools/webpack/src/ivy/system.ts b/packages/ngtools/webpack/src/ivy/system.ts new file mode 100644 index 000000000000..ac51e8d546b1 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/system.ts @@ -0,0 +1,99 @@ +/** + * @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 { InputFileSystem } from 'webpack'; + +function shouldNotWrite(): never { + throw new Error('Webpack TypeScript System should not write.'); +} + +// Webpack's CachedInputFileSystem uses the default directory separator in the paths it uses +// for keys to its cache. If the keys do not match then the file watcher will not purge outdated +// files and cause stale data to be used in the next rebuild. TypeScript always uses a `/` (POSIX) +// directory separator internally which is also supported with Windows system APIs. However, +// if file operations are performed with the non-default directory separator, the Webpack cache +// will contain a key that will not be purged. +function createToSystemPath(): (path: string) => string { + if (process.platform === 'win32') { + const cache = new Map(); + + return (path) => { + let value = cache.get(path); + if (value === undefined) { + value = path.replace(/\//g, '\\'); + cache.set(path, value); + } + + return value; + }; + } + + // POSIX-like platforms retain the existing directory separator + return (path) => path; +} + +export function createWebpackSystem(input: InputFileSystem, currentDirectory: string): ts.System { + const toSystemPath = createToSystemPath(); + + const system: ts.System = { + ...ts.sys, + readFile(path: string) { + let data; + try { + data = input.readFileSync(toSystemPath(path)); + } catch { + return undefined; + } + + // Strip BOM if present + let start = 0; + if (data.length > 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) { + start = 3; + } + + return data.toString('utf8', start); + }, + getFileSize(path: string) { + try { + return input.statSync(toSystemPath(path)).size; + } catch { + return 0; + } + }, + fileExists(path: string) { + try { + return input.statSync(toSystemPath(path)).isFile(); + } catch { + return false; + } + }, + directoryExists(path: string) { + try { + return input.statSync(toSystemPath(path)).isDirectory(); + } catch { + return false; + } + }, + getModifiedTime(path: string) { + try { + return input.statSync(toSystemPath(path)).mtime; + } catch { + return undefined; + } + }, + getCurrentDirectory() { + return currentDirectory; + }, + writeFile: shouldNotWrite, + createDirectory: shouldNotWrite, + deleteFile: shouldNotWrite, + setModifiedTime: shouldNotWrite, + }; + + return system; +} diff --git a/packages/ngtools/webpack/src/ivy/transformation.ts b/packages/ngtools/webpack/src/ivy/transformation.ts new file mode 100644 index 000000000000..a76f3ccd0d0d --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/transformation.ts @@ -0,0 +1,141 @@ +/** + * @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 { constructorParametersDownlevelTransform } from '@angular/compiler-cli'; +import * as ts from 'typescript'; +import { elideImports } from '../transformers/elide_imports'; +import { removeIvyJitSupportCalls } from '../transformers/remove-ivy-jit-support-calls'; +import { replaceResources } from '../transformers/replace_resources'; + +export function createAotTransformers( + builder: ts.BuilderProgram, + options: { emitClassMetadata?: boolean; emitNgModuleScope?: boolean }, +): ts.CustomTransformers { + const getTypeChecker = () => builder.getProgram().getTypeChecker(); + const transformers: ts.CustomTransformers = { + before: [replaceBootstrap(getTypeChecker)], + after: [], + }; + + const removeClassMetadata = !options.emitClassMetadata; + const removeNgModuleScope = !options.emitNgModuleScope; + if (removeClassMetadata || removeNgModuleScope) { + // tslint:disable-next-line: no-non-null-assertion + transformers.after!.push( + removeIvyJitSupportCalls(removeClassMetadata, removeNgModuleScope, getTypeChecker), + ); + } + + return transformers; +} + +export function createJitTransformers( + builder: ts.BuilderProgram, + options: { directTemplateLoading?: boolean }, +): ts.CustomTransformers { + const getTypeChecker = () => builder.getProgram().getTypeChecker(); + + return { + before: [ + replaceResources(() => true, getTypeChecker, options.directTemplateLoading), + constructorParametersDownlevelTransform(builder.getProgram()), + ], + }; +} + +export function mergeTransformers( + first: ts.CustomTransformers, + second: ts.CustomTransformers, +): ts.CustomTransformers { + const result: ts.CustomTransformers = {}; + + if (first.before || second.before) { + result.before = [...(first.before || []), ...(second.before || [])]; + } + + if (first.after || second.after) { + result.after = [...(first.after || []), ...(second.after || [])]; + } + + if (first.afterDeclarations || second.afterDeclarations) { + result.afterDeclarations = [ + ...(first.afterDeclarations || []), + ...(second.afterDeclarations || []), + ]; + } + + return result; +} + +export function replaceBootstrap( + getTypeChecker: () => ts.TypeChecker, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + let bootstrapImport: ts.ImportDeclaration | undefined; + let bootstrapNamespace: ts.Identifier | undefined; + const replacedNodes: ts.Node[] = []; + + const visitNode: ts.Visitor = (node: ts.Node) => { + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) { + const target = node.expression; + if (target.text === 'platformBrowserDynamic') { + if (!bootstrapNamespace) { + bootstrapNamespace = ts.createUniqueName('__NgCli_bootstrap_'); + bootstrapImport = ts.createImportDeclaration( + undefined, + undefined, + ts.createImportClause(undefined, ts.createNamespaceImport(bootstrapNamespace)), + ts.createLiteral('@angular/platform-browser'), + ); + } + replacedNodes.push(target); + + return ts.updateCall( + node, + ts.createPropertyAccess(bootstrapNamespace, 'platformBrowser'), + node.typeArguments, + node.arguments, + ); + } + } + + return ts.visitEachChild(node, visitNode, context); + }; + + return (sourceFile: ts.SourceFile) => { + let updatedSourceFile = ts.visitEachChild(sourceFile, visitNode, context); + + if (bootstrapImport) { + // Remove any unused platform browser dynamic imports + const removals = elideImports( + updatedSourceFile, + replacedNodes, + getTypeChecker, + context.getCompilerOptions(), + ).map((op) => op.target); + if (removals.length > 0) { + updatedSourceFile = ts.visitEachChild( + updatedSourceFile, + (node) => (removals.includes(node) ? undefined : node), + context, + ); + } + + // Add new platform browser import + return ts.updateSourceFileNode( + updatedSourceFile, + ts.setTextRange( + ts.createNodeArray([bootstrapImport, ...updatedSourceFile.statements]), + sourceFile.statements, + ), + ); + } else { + return updatedSourceFile; + } + }; + }; +} diff --git a/packages/ngtools/webpack/src/resource_loader.ts b/packages/ngtools/webpack/src/resource_loader.ts index cb3cbc234cdb..978eecddf029 100644 --- a/packages/ngtools/webpack/src/resource_loader.ts +++ b/packages/ngtools/webpack/src/resource_loader.ts @@ -32,11 +32,34 @@ export class WebpackResourceLoader { private _cachedSources = new Map(); private _cachedEvaluatedSources = new Map(); - constructor() {} + private buildTimestamp?: number; + public changedFiles = new Set(); - update(parentCompilation: any) { + update(parentCompilation: import('webpack').compilation.Compilation) { this._parentCompilation = parentCompilation; this._context = parentCompilation.context; + + // Update changed file list + if (this.buildTimestamp !== undefined) { + this.changedFiles.clear(); + for (const [file, time] of parentCompilation.fileTimestamps) { + if (this.buildTimestamp < time) { + this.changedFiles.add(file); + } + } + } + this.buildTimestamp = Date.now(); + } + + getModifiedResourceFiles() { + const modifiedResources = new Set(); + for (const changedFile of this.changedFiles) { + this.getAffectedResources( + changedFile, + ).forEach((affected: string) => modifiedResources.add(affected)); + } + + return modifiedResources; } getResourceDependencies(filePath: string) { @@ -47,6 +70,10 @@ export class WebpackResourceLoader { return this._reverseDependencies.get(file) || []; } + setAffectedResources(file: string, resources: Iterable) { + this._reverseDependencies.set(file, new Set(resources)); + } + private async _compile(filePath: string): Promise { if (!this._parentCompilation) { From 30806662e12523743da6483b6c9c7d873057ad7b Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 21 Apr 2020 18:24:37 -0400 Subject: [PATCH 2/4] refactor(@angular-devkit/build-angular): integrate Ivy Webpack compiler plugin This change integrates the Ivy Webpack compiler plugin into the browser builder. When Ivy is enabled, which is the default behavior for applications, this plugin will now be used. If needed, the previous plugin can still be used by enabling the `NG_BUILD_IVY_LEGACY` environment variable. --- .../src/utils/environment-options.ts | 5 + .../src/webpack/configs/common.ts | 8 ++ .../src/webpack/configs/typescript.ts | 134 +++++++++++++++--- packages/ngtools/webpack/src/ivy/loader.ts | 4 - 4 files changed, 131 insertions(+), 20 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/utils/environment-options.ts b/packages/angular_devkit/build_angular/src/utils/environment-options.ts index 2d896edb9389..5c1de06407ad 100644 --- a/packages/angular_devkit/build_angular/src/utils/environment-options.ts +++ b/packages/angular_devkit/build_angular/src/utils/environment-options.ts @@ -82,3 +82,8 @@ export const cachingBasePath = (() => { // Build profiling const profilingVariable = process.env['NG_BUILD_PROFILING']; export const profilingEnabled = isPresent(profilingVariable) && isEnabled(profilingVariable); + +// Legacy Webpack plugin with Ivy +const legacyIvyVariable = process.env['NG_BUILD_IVY_LEGACY']; +export const legacyIvyPluginEnabled = + isPresent(legacyIvyVariable) && !isDisabled(legacyIvyVariable); diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts index a22fb386c7f2..7732cca8feb2 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts @@ -350,6 +350,14 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { if (buildOptions.namedChunks && !isWebpackFiveOrHigher()) { extraPlugins.push(new NamedLazyChunksPlugin()); + + // Provide full names for lazy routes that use the deprecated string format + extraPlugins.push( + new ContextReplacementPlugin( + /\@angular[\\\/]core[\\\/]/, + (data: { chunkName?: string }) => (data.chunkName = '[request]'), + ), + ); } if (!differentialLoadingMode) { diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts b/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts index 6e854baf8130..9be2ce30ac17 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/typescript.ts @@ -7,15 +7,79 @@ */ // tslint:disable // TODO: cleanup this file, it's copied as is from Angular CLI. +import { CompilerOptions } from '@angular/compiler-cli'; import { buildOptimizerLoaderPath } from '@angular-devkit/build-optimizer'; -import * as path from 'path'; import { AngularCompilerPlugin, AngularCompilerPluginOptions, NgToolsLoader, - PLATFORM + PLATFORM, + ivy, } from '@ngtools/webpack'; +import * as path from 'path'; +import { RuleSetLoader } from 'webpack'; import { WebpackConfigOptions, BuildOptions } from '../../utils/build-options'; +import { legacyIvyPluginEnabled } from '../../utils/environment-options'; + +function canUseIvyPlugin(wco: WebpackConfigOptions): boolean { + // Can only be used with Ivy + if (!wco.tsConfig.options.enableIvy) { + return false; + } + + // Allow fallback to legacy build system via environment variable ('NG_BUILD_IVY_LEGACY=1') + if (legacyIvyPluginEnabled) { + wco.logger.warn( + '"NG_BUILD_IVY_LEGACY" environment variable detected. Using legacy Ivy build system.', + ); + + return false; + } + + // Lazy modules option uses the deprecated string format for lazy routes + if (wco.buildOptions.lazyModules && wco.buildOptions.lazyModules.length > 0) { + return false; + } + + // This pass relies on internals of the original plugin + if (wco.buildOptions.experimentalRollupPass) { + return false; + } + + return true; +} + +function createIvyPlugin( + wco: WebpackConfigOptions, + aot: boolean, + tsconfig: string, +): ivy.AngularWebpackPlugin { + const { buildOptions } = wco; + const optimize = buildOptions.optimization.scripts; + + const compilerOptions: CompilerOptions = { + skipTemplateCodegen: !aot, + sourceMap: buildOptions.sourceMap.scripts, + }; + + if (buildOptions.preserveSymlinks !== undefined) { + compilerOptions.preserveSymlinks = buildOptions.preserveSymlinks; + } + + const fileReplacements: Record = {}; + if (buildOptions.fileReplacements) { + for (const replacement of buildOptions.fileReplacements) { + fileReplacements[replacement.replace] = replacement.with; + } + } + + return new ivy.AngularWebpackPlugin({ + tsconfig, + compilerOptions, + fileReplacements, + emitNgModuleScope: !optimize, + }); +} function _pluginOptionsOverrides( buildOptions: BuildOptions, @@ -103,40 +167,78 @@ function _createAotPlugin( export function getNonAotConfig(wco: WebpackConfigOptions) { const { tsConfigPath } = wco; + const useIvyOnlyPlugin = canUseIvyPlugin(wco); return { - module: { rules: [{ test: /\.tsx?$/, loader: NgToolsLoader }] }, - plugins: [_createAotPlugin(wco, { tsConfigPath, skipCodeGeneration: true })] + module: { + rules: [ + { + test: useIvyOnlyPlugin ? /\.[jt]sx?$/ : /\.tsx?$/, + loader: useIvyOnlyPlugin + ? ivy.AngularWebpackLoaderPath + : NgToolsLoader, + }, + ], + }, + plugins: [ + useIvyOnlyPlugin + ? createIvyPlugin(wco, false, tsConfigPath) + : _createAotPlugin(wco, { tsConfigPath, skipCodeGeneration: true }), + ], }; } export function getAotConfig(wco: WebpackConfigOptions, i18nExtract = false) { const { tsConfigPath, buildOptions } = wco; + const optimize = buildOptions.optimization.scripts; + const useIvyOnlyPlugin = canUseIvyPlugin(wco) && !i18nExtract; - const loaders: any[] = [NgToolsLoader]; + let buildOptimizerRules: RuleSetLoader[] = []; if (buildOptions.buildOptimizer) { - loaders.unshift({ + buildOptimizerRules = [{ loader: buildOptimizerLoaderPath, options: { sourceMap: buildOptions.sourceMap.scripts } - }); + }]; } - const test = /(?:\.ngfactory\.js|\.ngstyle\.js|\.tsx?)$/; - const optimize = wco.buildOptions.optimization.scripts; - return { - module: { rules: [{ test, use: loaders }] }, + module: { + rules: [ + { + test: useIvyOnlyPlugin ? /\.tsx?$/ : /(?:\.ngfactory\.js|\.ngstyle\.js|\.tsx?)$/, + use: [ + ...buildOptimizerRules, + useIvyOnlyPlugin ? ivy.AngularWebpackLoaderPath : NgToolsLoader, + ], + }, + // "allowJs" support with ivy plugin - ensures build optimizer is not run twice + ...(useIvyOnlyPlugin + ? [ + { + test: /\.jsx?$/, + use: [ivy.AngularWebpackLoaderPath], + }, + ] + : []), + ], + }, plugins: [ - _createAotPlugin( - wco, - { tsConfigPath, emitClassMetadata: !optimize, emitNgModuleScope: !optimize }, - i18nExtract, - ), + useIvyOnlyPlugin + ? createIvyPlugin(wco, true, tsConfigPath) + : _createAotPlugin( + wco, + { tsConfigPath, emitClassMetadata: !optimize, emitNgModuleScope: !optimize }, + i18nExtract, + ), ], }; } export function getTypescriptWorkerPlugin(wco: WebpackConfigOptions, workerTsConfigPath: string) { + if (canUseIvyPlugin(wco)) { + return createIvyPlugin(wco, false, workerTsConfigPath); + } + const { buildOptions } = wco; let pluginOptions: AngularCompilerPluginOptions = { diff --git a/packages/ngtools/webpack/src/ivy/loader.ts b/packages/ngtools/webpack/src/ivy/loader.ts index 11a09a4dbbf0..5b4a7a513f04 100644 --- a/packages/ngtools/webpack/src/ivy/loader.ts +++ b/packages/ngtools/webpack/src/ivy/loader.ts @@ -15,10 +15,6 @@ export function angularWebpackLoader( // tslint:disable-next-line: no-any map: any, ) { - if (this.loaderIndex !== this.loaders.length - 1) { - this.emitWarning('The Angular Webpack loader does not support chaining prior to the loader.'); - } - const callback = this.async(); if (!callback) { throw new Error('Invalid webpack version'); From c2e111c72177015ded6bd6e0ccfec351ec651e6a Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Sun, 6 Sep 2020 13:23:15 -0400 Subject: [PATCH 3/4] refactor(@ngtools/webpack): allow paths plugin to update compiler options This change allows the compiler options used by the TypeScript paths plugin to be updated if the TypeScript configuration file is changed during a rebuild. --- packages/ngtools/webpack/src/paths-plugin.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ngtools/webpack/src/paths-plugin.ts b/packages/ngtools/webpack/src/paths-plugin.ts index 46bf71ebe1cb..4b461cf91f1d 100644 --- a/packages/ngtools/webpack/src/paths-plugin.ts +++ b/packages/ngtools/webpack/src/paths-plugin.ts @@ -16,14 +16,14 @@ export interface TypeScriptPathsPluginOptions extends Pick { return new Promise((resolve, reject) => { @@ -46,6 +46,10 @@ export class TypeScriptPathsPlugin { resolver.getHook('described-resolve').tapPromise( 'TypeScriptPathsPlugin', async (request: NormalModuleFactoryRequest, resolveContext: {}) => { + if (!this.options) { + throw new Error('TypeScriptPathsPlugin options were not provided.'); + } + if (!request || request.typescriptPathMapped) { return; } @@ -70,11 +74,11 @@ export class TypeScriptPathsPlugin { return; } - const replacements = findReplacements(originalRequest, this._options.paths || {}); + const replacements = findReplacements(originalRequest, this.options.paths || {}); for (const potential of replacements) { const potentialRequest = { ...request, - request: path.resolve(this._options.baseUrl || '', potential), + request: path.resolve(this.options.baseUrl || '', potential), typescriptPathMapped: true, }; const result = await resolveAsync(potentialRequest, resolveContext); From 6c9796d50c8519846c4c9e9be19d643c5578e896 Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Wed, 4 Nov 2020 15:57:25 -0500 Subject: [PATCH 4/4] test(@angular-devkit/build-angular): improve resilience of lazy module rebuild test Rebuild tests that involve file watching can be very flaky on CI. This change adds a debounce time which is also used in the other rebuild tests within the package. --- .../build_angular/src/browser/specs/lazy-module_spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts b/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts index ffb07abf2026..80673c321367 100644 --- a/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts +++ b/packages/angular_devkit/build_angular/src/browser/specs/lazy-module_spec.ts @@ -9,7 +9,7 @@ import { Architect } from '@angular-devkit/architect'; import { TestProjectHost } from '@angular-devkit/architect/testing'; import { logging } from '@angular-devkit/core'; -import { take, tap, timeout } from 'rxjs/operators'; +import { debounceTime, take, tap } from 'rxjs/operators'; import { browserBuild, createArchitect, @@ -117,7 +117,7 @@ describe('Browser Builder lazy modules', () => { const run = await architect.scheduleTarget(target, overrides); await run.output .pipe( - timeout(15000), + debounceTime(3000), tap(buildEvent => { buildNumber++; switch (buildNumber) {