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) { 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/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..5b4a7a513f04 --- /dev/null +++ b/packages/ngtools/webpack/src/ivy/loader.ts @@ -0,0 +1,73 @@ +/** + * @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, +) { + 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/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); 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) {