diff --git a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts index 9715e4562e8e..a1e0c53bd968 100644 --- a/packages/angular_devkit/build_angular/src/utils/process-bundle.ts +++ b/packages/angular_devkit/build_angular/src/utils/process-bundle.ts @@ -32,6 +32,7 @@ import { } from 'webpack-sources'; import { allowMangle, allowMinify, shouldBeautify } from './environment-options'; import { I18nOptions } from './i18n-options'; +import { isWebpackFiveOrHigher } from './webpack-version'; type LocalizeUtilities = typeof import('@angular/localize/src/tools/src/source_file_utils'); @@ -219,21 +220,25 @@ async function mergeSourceMaps( filename: string, fast = false, ): Promise { - if (fast) { + // Webpack 5 terser sourcemaps currently fail merging with the high-quality method + // TODO_WEBPACK_5: Investigate high-quality sourcemap merge failures + if (fast || isWebpackFiveOrHigher()) { return mergeSourceMapsFast(inputSourceMap, resultSourceMap); } // SourceMapSource produces high-quality sourcemaps - // The last argument is not yet in the typings - // tslint:disable-next-line: no-any - return new (SourceMapSource as any)( + // Final sourcemap will always be available when providing the input sourcemaps + // tslint:disable-next-line: no-non-null-assertion + const finalSourceMap = new SourceMapSource( resultCode, filename, resultSourceMap, inputCode, inputSourceMap, true, - ).map(); + ).map()!; + + return finalSourceMap; } async function mergeSourceMapsFast(first: RawSourceMap, second: RawSourceMap) { diff --git a/packages/angular_devkit/build_angular/src/utils/webpack-diagnostics.ts b/packages/angular_devkit/build_angular/src/utils/webpack-diagnostics.ts index 7e7fe746e66d..d7ea3b4c44d6 100644 --- a/packages/angular_devkit/build_angular/src/utils/webpack-diagnostics.ts +++ b/packages/angular_devkit/build_angular/src/utils/webpack-diagnostics.ts @@ -6,21 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ import * as webpack from 'webpack'; +import { isWebpackFiveOrHigher } from './webpack-version'; const WebpackError = require('webpack/lib/WebpackError'); -const isWebpackFiveOrHigher = (() => { - if (typeof webpack.version === 'string') { - const versionParts = webpack.version.split('.'); - if (versionParts[0] && Number(versionParts[0]) >= 5) { - return true; - } - } - - return false; -})(); export function addWarning(compilation: webpack.compilation.Compilation, message: string): void { - if (isWebpackFiveOrHigher) { + if (isWebpackFiveOrHigher()) { compilation.warnings.push(new WebpackError(message)); } else { // Allows building with either Webpack 4 or 5+ types @@ -30,7 +21,7 @@ export function addWarning(compilation: webpack.compilation.Compilation, message } export function addError(compilation: webpack.compilation.Compilation, message: string): void { - if (isWebpackFiveOrHigher) { + if (isWebpackFiveOrHigher()) { compilation.errors.push(new WebpackError(message)); } else { // Allows building with either Webpack 4 or 5+ types diff --git a/packages/angular_devkit/build_angular/src/utils/webpack-version.ts b/packages/angular_devkit/build_angular/src/utils/webpack-version.ts new file mode 100644 index 000000000000..3cb37086265c --- /dev/null +++ b/packages/angular_devkit/build_angular/src/utils/webpack-version.ts @@ -0,0 +1,28 @@ +/** + * @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 webpack from 'webpack'; + +let cachedIsWebpackFiveOrHigher: boolean | undefined; +export function isWebpackFiveOrHigher(): boolean { + if (cachedIsWebpackFiveOrHigher === undefined) { + cachedIsWebpackFiveOrHigher = false; + if (typeof webpack.version === 'string') { + const versionParts = webpack.version.split('.'); + if (versionParts[0] && Number(versionParts[0]) >= 5) { + cachedIsWebpackFiveOrHigher = true; + } + } + } + + return cachedIsWebpackFiveOrHigher; +} + +// tslint:disable-next-line: no-any +export function withWebpackFourOrFive(webpackFourValue: T, webpackFiveValue: R): any { + return isWebpackFiveOrHigher() ? webpackFiveValue : webpackFourValue; +} diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/browser.ts b/packages/angular_devkit/build_angular/src/webpack/configs/browser.ts index f325a11917b3..591d6efe2360 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/browser.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/browser.ts @@ -7,6 +7,7 @@ */ import * as webpack from 'webpack'; import { WebpackConfigOptions } from '../../utils/build-options'; +import { isWebpackFiveOrHigher, withWebpackFourOrFive } from '../../utils/webpack-version'; import { CommonJsUsageWarnPlugin } from '../plugins'; import { getSourceMapDevTool } from '../utils/helpers'; @@ -37,7 +38,13 @@ export function getBrowserConfig(wco: WebpackConfigOptions): webpack.Configurati })); } - if (extractLicenses) { + // TODO_WEBPACK_5: Investigate build/serve issues with the `license-webpack-plugin` package + if (extractLicenses && isWebpackFiveOrHigher()) { + wco.logger.warn( + 'WARNING: License extraction is currently disabled when using Webpack 5. ' + + 'This is temporary and will be corrected in a future update.', + ); + } else if (extractLicenses) { const LicenseWebpackPlugin = require('license-webpack-plugin').LicenseWebpackPlugin; extraPlugins.push(new LicenseWebpackPlugin({ stats: { @@ -71,6 +78,7 @@ export function getBrowserConfig(wco: WebpackConfigOptions): webpack.Configurati resolve: { mainFields: ['es2015', 'browser', 'module', 'main'], }, + ...withWebpackFourOrFive({}, { target: ['web', 'es5'] }), output: { crossOriginLoading, }, 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 4b734c4af204..117cbd7b574c 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/common.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/common.ts @@ -37,6 +37,7 @@ import { shouldBeautify, } from '../../utils/environment-options'; import { findAllNodeModules } from '../../utils/find-up'; +import { isWebpackFiveOrHigher, withWebpackFourOrFive } from '../../utils/webpack-version'; import { BundleBudgetPlugin, DedupeModuleResolvePlugin, @@ -317,7 +318,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { ); } - if (buildOptions.namedChunks) { + if (buildOptions.namedChunks && !isWebpackFiveOrHigher()) { extraPlugins.push(new NamedLazyChunksPlugin()); } @@ -475,9 +476,7 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { extensions: ['.ts', '.tsx', '.mjs', '.js'], symlinks: !buildOptions.preserveSymlinks, modules: [wco.tsConfig.options.baseUrl || projectRoot, 'node_modules'], - plugins: [ - PnpWebpackPlugin, - ], + plugins: isWebpackFiveOrHigher() ? [] : [PnpWebpackPlugin], }, resolveLoader: { symlinks: !buildOptions.preserveSymlinks, @@ -488,12 +487,12 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { 'node_modules', ...findAllNodeModules(__dirname, projectRoot), ], - plugins: [PnpWebpackPlugin.moduleLoader(module)], + plugins: isWebpackFiveOrHigher() ? [] : [PnpWebpackPlugin.moduleLoader(module)], }, context: root, entry: entryPoints, output: { - futureEmitAssets: true, + ...withWebpackFourOrFive({ futureEmitAssets: true }, {}), path: path.resolve(root, buildOptions.outputPath), publicPath: buildOptions.deployUrl, filename: `[name]${targetInFileName}${hashFormat.chunk}.js`, @@ -501,7 +500,10 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { watch: buildOptions.watch, watchOptions: { poll: buildOptions.poll, - ignored: buildOptions.poll === undefined ? undefined : /[\\\/]node_modules[\\\/]/, + ignored: + buildOptions.poll === undefined + ? undefined + : withWebpackFourOrFive(/[\\\/]node_modules[\\\/]/, 'node_modules/**'), }, performance: { hints: false, @@ -586,9 +588,12 @@ export function getCommonConfig(wco: WebpackConfigOptions): Configuration { }, optimization: { minimizer: extraMinimizers, - moduleIds: 'hashed', - noEmitOnErrors: true, + moduleIds: withWebpackFourOrFive('hashed', 'deterministic'), + ...withWebpackFourOrFive({}, buildOptions.namedChunks ? { chunkIds: 'named' } : {}), + ...withWebpackFourOrFive({ noEmitOnErrors: true }, { emitOnErrors: false }), }, + // TODO_WEBPACK_5: Investigate non-working cache in development builds + ...withWebpackFourOrFive({}, { cache: false }), plugins: [ // Always replace the context for the System.import in angular/core to prevent warnings. // https://github.com/angular/angular/issues/11580 diff --git a/packages/angular_devkit/build_angular/src/webpack/configs/server.ts b/packages/angular_devkit/build_angular/src/webpack/configs/server.ts index 6f643256f653..bbbfa72f04e2 100644 --- a/packages/angular_devkit/build_angular/src/webpack/configs/server.ts +++ b/packages/angular_devkit/build_angular/src/webpack/configs/server.ts @@ -8,8 +8,14 @@ import { isAbsolute } from 'path'; import { Configuration, ContextReplacementPlugin } from 'webpack'; import { WebpackConfigOptions } from '../../utils/build-options'; +import { isWebpackFiveOrHigher } from '../../utils/webpack-version'; import { getSourceMapDevTool } from '../utils/helpers'; +type ExternalHookWebpack5 = ( + data: { context: string; request: string }, + callback: (err?: Error, result?: string) => void, +) => void; + /** * Returns a partial Webpack configuration specific to creating a bundle for node * @param wco Options which include the build options and app config @@ -29,7 +35,13 @@ export function getServerConfig(wco: WebpackConfigOptions): Configuration { const externals: Configuration['externals'] = [...externalDependencies]; if (!bundleDependencies) { - externals.push(externalizePackages); + if (isWebpackFiveOrHigher()) { + const hook: ExternalHookWebpack5 = ({ context, request }, callback) => + externalizePackages(request, context, callback); + externals.push(hook); + } else { + externals.push(externalizePackages as unknown as ExternalHookWebpack5); + } } const config: Configuration = { diff --git a/packages/ngtools/webpack/src/virtual_file_system_decorator.ts b/packages/ngtools/webpack/src/virtual_file_system_decorator.ts index 54428d8ed50a..484060f5e0fb 100644 --- a/packages/ngtools/webpack/src/virtual_file_system_decorator.ts +++ b/packages/ngtools/webpack/src/virtual_file_system_decorator.ts @@ -10,6 +10,7 @@ import { Stats } from 'fs'; import { InputFileSystem } from 'webpack'; import { WebpackCompilerHost } from './compiler_host'; import { NodeWatchFileSystemInterface } from './webpack'; +import { isWebpackFiveOrHigher } from './webpack-version'; export const NodeWatchFileSystem: NodeWatchFileSystemInterface = require( 'webpack/lib/node/NodeWatchFileSystem'); @@ -112,106 +113,187 @@ export class VirtualWatchFileSystemDecorator extends NodeWatchFileSystem { super(_virtualInputFileSystem); } - watch = ( - files: Iterable, - dirs: Iterable, - missing: Iterable, - startTime: number, - options: {}, - callback: Parameters[5], - callbackUndelayed: (filename: string, timestamp: number) => void, - ): ReturnType => { - const reverseReplacements = new Map(); - const reverseTimestamps = (map: Map) => { - for (const entry of Array.from(map.entries())) { - const original = reverseReplacements.get(entry[0]); - if (original) { - map.set(original, entry[1]); - map.delete(entry[0]); + mapReplacements( + original: Iterable, + reverseReplacements: Map, + ): Iterable { + if (!this._replacements) { + return original; + } + const replacements = this._replacements; + + return [...original].map(file => { + if (typeof replacements === 'function') { + const replacement = getSystemPath(replacements(normalize(file))); + if (replacement !== file) { + reverseReplacements.set(replacement, file); } - } - return map; - }; + return replacement; + } else { + const replacement = replacements.get(normalize(file)); + if (replacement) { + const fullReplacement = getSystemPath(replacement); + reverseReplacements.set(fullReplacement, file); + + return fullReplacement; + } else { + return file; + } + } + }); + } - const newCallbackUndelayed = (filename: string, timestamp: number) => { - const original = reverseReplacements.get(filename); + reverseTimestamps( + map: Map, + reverseReplacements: Map, + ): Map { + for (const entry of Array.from(map.entries())) { + const original = reverseReplacements.get(entry[0]); if (original) { - this._virtualInputFileSystem.purge(original); - callbackUndelayed(original, timestamp); - } else { - callbackUndelayed(filename, timestamp); + map.set(original, entry[1]); + map.delete(entry[0]); } - }; + } + + return map; + } - const newCallback: Parameters[5] = ( - err: Error | null, - filesModified: string[], - contextModified: string[], - missingModified: string[], - fileTimestamps: Map, - contextTimestamps: Map, - ) => { - // Update fileTimestamps with timestamps from virtual files. - const virtualFilesStats = this._virtualInputFileSystem.getVirtualFilesPaths() - .map((fileName) => ({ - path: fileName, - mtime: +this._virtualInputFileSystem.statSync(fileName).mtime, - })); - virtualFilesStats.forEach(stats => fileTimestamps.set(stats.path, +stats.mtime)); - callback( - err, - filesModified.map(value => reverseReplacements.get(value) || value), - contextModified.map(value => reverseReplacements.get(value) || value), - missingModified.map(value => reverseReplacements.get(value) || value), - reverseTimestamps(fileTimestamps), - reverseTimestamps(contextTimestamps), + createWebpack4Watch() { + return ( + files: Iterable, + dirs: Iterable, + missing: Iterable, + startTime: number, + options: {}, + callback: Parameters[5], + callbackUndelayed: (filename: string, timestamp: number) => void, + ): ReturnType => { + const reverseReplacements = new Map(); + + const newCallbackUndelayed = (filename: string, timestamp: number) => { + const original = reverseReplacements.get(filename); + if (original) { + this._virtualInputFileSystem.purge(original); + callbackUndelayed(original, timestamp); + } else { + callbackUndelayed(filename, timestamp); + } + }; + + const newCallback: Parameters[5] = ( + err: Error | null, + filesModified: string[], + contextModified: string[], + missingModified: string[], + fileTimestamps: Map, + contextTimestamps: Map, + ) => { + // Update fileTimestamps with timestamps from virtual files. + const virtualFilesStats = this._virtualInputFileSystem.getVirtualFilesPaths() + .map((fileName) => ({ + path: fileName, + mtime: +this._virtualInputFileSystem.statSync(fileName).mtime, + })); + virtualFilesStats.forEach(stats => fileTimestamps.set(stats.path, +stats.mtime)); + callback( + err, + filesModified.map(value => reverseReplacements.get(value) || value), + contextModified.map(value => reverseReplacements.get(value) || value), + missingModified.map(value => reverseReplacements.get(value) || value), + this.reverseTimestamps(fileTimestamps, reverseReplacements), + this.reverseTimestamps(contextTimestamps, reverseReplacements), + ); + }; + + const watcher = super.watch( + this.mapReplacements(files, reverseReplacements), + this.mapReplacements(dirs, reverseReplacements), + this.mapReplacements(missing, reverseReplacements), + startTime, + options, + newCallback, + newCallbackUndelayed, ); - }; - const mapReplacements = (original: Iterable): Iterable => { - if (!this._replacements) { - return original; - } - const replacements = this._replacements; + return { + close: () => watcher.close(), + pause: () => watcher.pause(), + getFileTimestamps: () => + this.reverseTimestamps(watcher.getFileTimestamps(), reverseReplacements), + getContextTimestamps: () => + this.reverseTimestamps(watcher.getContextTimestamps(), reverseReplacements), + }; + }; + } - return [...original].map(file => { - if (typeof replacements === 'function') { - const replacement = getSystemPath(replacements(normalize(file))); - if (replacement !== file) { - reverseReplacements.set(replacement, file); - } + createWebpack5Watch() { + return ( + files: Iterable, + dirs: Iterable, + missing: Iterable, + startTime: number, + options: {}, + callback: Parameters[5], + callbackUndelayed: (filename: string, timestamp: number) => void, + ): ReturnType => { + const reverseReplacements = new Map(); - return replacement; + const newCallbackUndelayed = (filename: string, timestamp: number) => { + const original = reverseReplacements.get(filename); + if (original) { + this._virtualInputFileSystem.purge(original); + callbackUndelayed(original, timestamp); } else { - const replacement = replacements.get(normalize(file)); - if (replacement) { - const fullReplacement = getSystemPath(replacement); - reverseReplacements.set(fullReplacement, file); - - return fullReplacement; - } else { - return file; - } + callbackUndelayed(filename, timestamp); } - }); - }; + }; - const watcher = super.watch( - mapReplacements(files), - mapReplacements(dirs), - mapReplacements(missing), - startTime, - options, - newCallback, - newCallbackUndelayed, - ); - - return { - close: () => watcher.close(), - pause: () => watcher.pause(), - getFileTimestamps: () => reverseTimestamps(watcher.getFileTimestamps()), - getContextTimestamps: () => reverseTimestamps(watcher.getContextTimestamps()), + const newCallback = ( + err: Error, + // tslint:disable-next-line: no-any + fileTimeInfoEntries: Map, + // tslint:disable-next-line: no-any + contextTimeInfoEntries: Map, + missing: Set, + removals: Set, + ) => { + // Update fileTimestamps with timestamps from virtual files. + const virtualFilesStats = this._virtualInputFileSystem.getVirtualFilesPaths() + .map((fileName) => ({ + path: fileName, + mtime: +this._virtualInputFileSystem.statSync(fileName).mtime, + })); + virtualFilesStats.forEach(stats => fileTimeInfoEntries.set(stats.path, +stats.mtime)); + callback( + err, + this.reverseTimestamps(fileTimeInfoEntries, reverseReplacements), + this.reverseTimestamps(contextTimeInfoEntries, reverseReplacements), + new Set([...missing].map(value => reverseReplacements.get(value) || value)), + new Set([...removals].map(value => reverseReplacements.get(value) || value)), + ); + }; + + const watcher = super.watch( + this.mapReplacements(files, reverseReplacements), + this.mapReplacements(dirs, reverseReplacements), + this.mapReplacements(missing, reverseReplacements), + startTime, + options, + newCallback, + newCallbackUndelayed, + ); + + return { + close: () => watcher.close(), + pause: () => watcher.pause(), + getFileTimeInfoEntries: () => + this.reverseTimestamps(watcher.getFileTimeInfoEntries(), reverseReplacements), + getContextTimeInfoEntries: () => + this.reverseTimestamps(watcher.getContextTimeInfoEntries(), reverseReplacements), + }; }; } + + watch = isWebpackFiveOrHigher() ? this.createWebpack5Watch : this.createWebpack4Watch(); } diff --git a/tests/legacy-cli/e2e/tests/misc/webpack-5.ts b/tests/legacy-cli/e2e/tests/misc/webpack-5.ts new file mode 100644 index 000000000000..1a1dea49b88c --- /dev/null +++ b/tests/legacy-cli/e2e/tests/misc/webpack-5.ts @@ -0,0 +1,33 @@ +import { rimraf } from '../../utils/fs'; +import { killAllProcesses, ng, silentYarn } from '../../utils/process'; +import { ngServe, updateJsonFile } from '../../utils/project'; + +export default async function() { + // Setup project for yarn usage + await rimraf('node_modules'); + await updateJsonFile('package.json', (json) => { + json.resolutions = { webpack: '5.0.0-beta.30' }; + }); + await silentYarn(); + await silentYarn('webdriver-update'); + + // Ensure webpack 5 is used + const { stdout } = await silentYarn('list', '--pattern', 'webpack'); + if (!/\swebpack@5/.test(stdout)) { + throw new Error('Expected Webpack 5 to be installed.'); + } + if (/\swebpack@4/.test(stdout)) { + throw new Error('Expected Webpack 4 to not be installed.'); + } + + // Execute the CLI with Webpack 5 + await ng('test', '--watch=false'); + await ng('build'); + await ng('build', '--prod'); + await ng('e2e'); + try { + await ngServe(); + } finally { + killAllProcesses(); + } +}