diff --git a/packages/@angular/cli/models/webpack-configs/typescript.ts b/packages/@angular/cli/models/webpack-configs/typescript.ts index 51278a38b1d7..e00e212d355f 100644 --- a/packages/@angular/cli/models/webpack-configs/typescript.ts +++ b/packages/@angular/cli/models/webpack-configs/typescript.ts @@ -139,8 +139,12 @@ export function getAotConfig(wco: WebpackConfigOptions) { }]; } + const test = AngularCompilerPlugin.isSupported() + ? /(?:\.ngfactory\.js|\.ngstyle\.js|\.ts)$/ + : /\.ts$/; + return { - module: { rules: [{ test: /\.ts$/, use: [...boLoader, webpackLoader] }] }, + module: { rules: [{ test, use: [...boLoader, webpackLoader] }] }, plugins: [ _createAotPlugin(wco, pluginOptions) ] }; } diff --git a/packages/@ngtools/webpack/README.md b/packages/@ngtools/webpack/README.md index 8e634d118149..82b035594df9 100644 --- a/packages/@ngtools/webpack/README.md +++ b/packages/@ngtools/webpack/README.md @@ -3,7 +3,35 @@ Webpack plugin that AoT compiles your Angular components and modules. ## Usage -In your webpack config, add the following plugin and loader: + +In your webpack config, add the following plugin and loader. + +Angular version 5 and up, use `AngularCompilerPlugin`: + +```typescript +import {AotPlugin} from '@ngtools/webpack' + +exports = { /* ... */ + module: { + rules: [ + { + test: /(?:\.ngfactory\.js|\.ngstyle\.js|\.ts)$/, + loader: '@ngtools/webpack', + sourcemap: true + } + ] + }, + + plugins: [ + new AngularCompilerPlugin({ + tsConfigPath: 'path/to/tsconfig.json', + entryModule: 'path/to/app.module#AppModule' + }) + ] +} +``` + +Angular version 2 and 4, use `AotPlugin`: ```typescript import {AotPlugin} from '@ngtools/webpack' @@ -14,6 +42,7 @@ exports = { /* ... */ { test: /\.ts$/, loader: '@ngtools/webpack', + sourcemap: true } ] }, @@ -27,9 +56,7 @@ exports = { /* ... */ } ``` -The loader works with the webpack plugin to compile your TypeScript. It's important to include both, and to not include any other TypeScript compiler loader. - -For Angular version 5 and up, import `AngularCompilerPlugin` instead of `AotPlugin`. +The loader works with webpack plugin to compile your TypeScript. It's important to include both, and to not include any other TypeScript compiler loader. ## Options diff --git a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts index 476d12d12e2c..11d0fb1da477 100644 --- a/packages/@ngtools/webpack/src/angular_compiler_plugin.ts +++ b/packages/@ngtools/webpack/src/angular_compiler_plugin.ts @@ -4,7 +4,6 @@ import * as path from 'path'; import * as ts from 'typescript'; const ContextElementDependency = require('webpack/lib/dependencies/ContextElementDependency'); -const NodeWatchFileSystem = require('webpack/lib/node/NodeWatchFileSystem'); const treeKill = require('tree-kill'); import { WebpackResourceLoader } from './resource_loader'; @@ -12,7 +11,10 @@ import { WebpackCompilerHost } from './compiler_host'; import { Tapable } from './webpack'; import { PathsPlugin } from './paths-plugin'; import { findLazyRoutes, LazyRouteMap } from './lazy_routes'; -import { VirtualFileSystemDecorator } from './virtual_file_system_decorator'; +import { + VirtualFileSystemDecorator, + VirtualWatchFileSystemDecorator +} from './virtual_file_system_decorator'; import { resolveEntryModuleFromMain } from './entry_resolver'; import { TransformOperation, @@ -21,6 +23,7 @@ import { exportNgFactory, exportLazyModuleMap, registerLocaleData, + findResources, replaceResources, } from './transformers'; import { time, timeEnd } from './benchmark'; @@ -483,7 +486,7 @@ export class AngularCompilerPlugin implements Tapable { compiler.plugin('environment', () => { compiler.inputFileSystem = new VirtualFileSystemDecorator( compiler.inputFileSystem, this._compilerHost); - compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem); + compiler.watchFileSystem = new VirtualWatchFileSystemDecorator(compiler.inputFileSystem); }); // Add lazy modules to the context module for @angular/core @@ -781,15 +784,21 @@ export class AngularCompilerPlugin implements Tapable { } getDependencies(fileName: string): string[] { - const sourceFile = this._compilerHost.getSourceFile(fileName, ts.ScriptTarget.Latest); + const resolvedFileName = this._compilerHost.resolve(fileName); + const sourceFile = this._compilerHost.getSourceFile(resolvedFileName, ts.ScriptTarget.Latest); + if (!sourceFile) { + return []; + } + const options = this._compilerOptions; const host = this._compilerHost; const cache = this._moduleResolutionCache; - return findAstNodes(null, sourceFile, ts.SyntaxKind.ImportDeclaration) + const esImports = findAstNodes(null, sourceFile, + ts.SyntaxKind.ImportDeclaration) .map(decl => { const moduleName = (decl.moduleSpecifier as ts.StringLiteral).text; - const resolved = ts.resolveModuleName(moduleName, fileName, options, host, cache); + const resolved = ts.resolveModuleName(moduleName, resolvedFileName, options, host, cache); if (resolved.resolvedModule) { return resolved.resolvedModule.resolvedFileName; @@ -798,6 +807,15 @@ export class AngularCompilerPlugin implements Tapable { } }) .filter(x => x); + + const resourceImports = findResources(sourceFile) + .map((resourceReplacement) => resourceReplacement.resourcePaths) + .reduce((prev, curr) => prev.concat(curr), []) + .map((resourcePath) => path.resolve(path.dirname(resolvedFileName), resourcePath)) + .reduce((prev, curr) => + prev.concat(...this._resourceLoader.getResourceDependencies(curr)), []); + + return [...esImports, ...resourceImports]; } // This code mostly comes from `performCompilation` in `@angular/compiler-cli`. diff --git a/packages/@ngtools/webpack/src/compiler_host.ts b/packages/@ngtools/webpack/src/compiler_host.ts index 853c434f3495..f2ea6d4f346b 100644 --- a/packages/@ngtools/webpack/src/compiler_host.ts +++ b/packages/@ngtools/webpack/src/compiler_host.ts @@ -1,5 +1,5 @@ import * as ts from 'typescript'; -import {basename, dirname, join} from 'path'; +import {basename, dirname, join, sep} from 'path'; import * as fs from 'fs'; import {WebpackResourceLoader} from './resource_loader'; @@ -96,6 +96,7 @@ export class WebpackCompilerHost implements ts.CompilerHost { private _delegate: ts.CompilerHost; private _files: {[path: string]: VirtualFileStats | null} = Object.create(null); private _directories: {[path: string]: VirtualDirStats | null} = Object.create(null); + private _cachedResources: {[path: string]: string | undefined} = Object.create(null); private _changedFiles: {[path: string]: boolean} = Object.create(null); private _changedDirs: {[path: string]: boolean} = Object.create(null); @@ -157,6 +158,11 @@ export class WebpackCompilerHost implements ts.CompilerHost { return Object.keys(this._changedFiles); } + getNgFactoryPaths(): string[] { + return Object.keys(this._files) + .filter(fileName => fileName.endsWith('.ngfactory.js') || fileName.endsWith('.ngstyle.js')); + } + invalidate(fileName: string): void { fileName = this.resolve(fileName); if (fileName in this._files) { @@ -284,9 +290,23 @@ export class WebpackCompilerHost implements ts.CompilerHost { readResource(fileName: string) { if (this._resourceLoader) { - // We still read it to add it to the compiler host file list. - this.readFile(fileName); - return this._resourceLoader.get(fileName); + const denormalizedFileName = fileName.replace(/\//g, sep); + const resourceDeps = this._resourceLoader.getResourceDependencies(denormalizedFileName); + + if (this._cachedResources[fileName] === undefined + || resourceDeps.some((dep) => this._changedFiles[this.resolve(dep)])) { + return this._resourceLoader.get(denormalizedFileName) + .then((resource) => { + // Add resource dependencies to the compiler host file list. + // This way we can check the changed files list to determine whether to use cache. + this._resourceLoader.getResourceDependencies(denormalizedFileName) + .forEach((dep) => this.readFile(dep)); + this._cachedResources[fileName] = resource; + return resource; + }); + } else { + return this._cachedResources[fileName]; + } } else { return this.readFile(fileName); } diff --git a/packages/@ngtools/webpack/src/loader.ts b/packages/@ngtools/webpack/src/loader.ts index 409645738a17..0eef2018d0fc 100644 --- a/packages/@ngtools/webpack/src/loader.ts +++ b/packages/@ngtools/webpack/src/loader.ts @@ -588,6 +588,14 @@ export function ngcLoader(this: LoaderContext & { _compilation: any }, source: s const dependencies = plugin.getDependencies(sourceFileName); dependencies.forEach(dep => this.addDependency(dep.replace(/\//g, path.sep))); + // Also add the original file dependencies to virtual files. + const virtualFilesRe = /\.(?:ngfactory|css\.shim\.ngstyle)\.js$/; + if (virtualFilesRe.test(sourceFileName)) { + const originalFile = sourceFileName.replace(virtualFilesRe, '.ts'); + const origDependencies = plugin.getDependencies(originalFile); + origDependencies.forEach(dep => this.addDependency(dep.replace(/\//g, path.sep))); + } + cb(null, result.outputText, result.sourceMap); }) .catch(err => { diff --git a/packages/@ngtools/webpack/src/resource_loader.ts b/packages/@ngtools/webpack/src/resource_loader.ts index 8753ac37603f..38dc019c6fbc 100644 --- a/packages/@ngtools/webpack/src/resource_loader.ts +++ b/packages/@ngtools/webpack/src/resource_loader.ts @@ -8,21 +8,15 @@ const SingleEntryPlugin = require('webpack/lib/SingleEntryPlugin'); interface CompilationOutput { - rendered: boolean; outputName: string; source: string; } -interface CachedCompilation { - outputName: string; - evaluatedSource?: string; -} - export class WebpackResourceLoader { private _parentCompilation: any; private _context: string; private _uniqueId = 0; - private _cache = new Map(); + private _resourceDependencies = new Map(); constructor() {} @@ -32,6 +26,10 @@ export class WebpackResourceLoader { this._uniqueId = 0; } + getResourceDependencies(filePath: string) { + return this._resourceDependencies.get(filePath) || []; + } + private _compile(filePath: string): Promise { if (!this._parentCompilation) { @@ -98,9 +96,10 @@ export class WebpackResourceLoader { } }); + // Save the dependencies for this resource. + this._resourceDependencies.set(outputName, childCompilation.fileDependencies); + resolve({ - // Boolean showing if this entry was changed since the last compilation. - rendered: entries[0].rendered, // Output name. outputName, // Compiled code. @@ -113,19 +112,19 @@ export class WebpackResourceLoader { private _evaluate(output: CompilationOutput): Promise { try { + const outputName = output.outputName; const vmContext = vm.createContext(Object.assign({ require: require }, global)); - const vmScript = new vm.Script(output.source, { filename: output.outputName }); + const vmScript = new vm.Script(output.source, { filename: outputName }); // Evaluate code and cast to string let evaluatedSource: string; evaluatedSource = vmScript.runInContext(vmContext); if (typeof evaluatedSource == 'string') { - this._cache.set(output.outputName, { outputName: output.outputName, evaluatedSource }); return Promise.resolve(evaluatedSource); } - return Promise.reject('The loader "' + output.outputName + '" didn\'t return a string.'); + return Promise.reject('The loader "' + outputName + '" didn\'t return a string.'); } catch (e) { return Promise.reject(e); } @@ -133,18 +132,6 @@ export class WebpackResourceLoader { get(filePath: string): Promise { return this._compile(filePath) - .then((result: CompilationOutput) => { - if (!result.rendered) { - // Check cache. - const outputName = result.outputName; - const cachedOutput = this._cache.get(outputName); - if (cachedOutput) { - // Return cached evaluatedSource. - return Promise.resolve(cachedOutput.evaluatedSource); - } - } - - return this._evaluate(result); - }); + .then((result: CompilationOutput) => this._evaluate(result)); } } diff --git a/packages/@ngtools/webpack/src/transformers/replace_resources.ts b/packages/@ngtools/webpack/src/transformers/replace_resources.ts index 186eab841e98..51091f2f0d18 100644 --- a/packages/@ngtools/webpack/src/transformers/replace_resources.ts +++ b/packages/@ngtools/webpack/src/transformers/replace_resources.ts @@ -10,6 +10,45 @@ import { export function replaceResources(sourceFile: ts.SourceFile): TransformOperation[] { const ops: TransformOperation[] = []; + const replacements = findResources(sourceFile); + + if (replacements.length > 0) { + + // Add the replacement operations. + ops.push(...(replacements.map((rep) => rep.replaceNodeOperation))); + + // If we added a require call, we need to also add typings for it. + // The typings need to be compatible with node typings, but also work by themselves. + + // interface NodeRequire {(id: string): any;} + const nodeRequireInterface = ts.createInterfaceDeclaration([], [], 'NodeRequire', [], [], [ + ts.createCallSignature([], [ + ts.createParameter([], [], undefined, 'id', undefined, + ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) + ) + ], ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)) + ]); + + // declare var require: NodeRequire; + const varRequire = ts.createVariableStatement( + [ts.createToken(ts.SyntaxKind.DeclareKeyword)], + [ts.createVariableDeclaration('require', ts.createTypeReferenceNode('NodeRequire', []))] + ); + + ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), nodeRequireInterface)); + ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), varRequire)); + } + + return ops; +} + +export interface ResourceReplacement { + resourcePaths: string[]; + replaceNodeOperation: ReplaceNodeOperation; +} + +export function findResources(sourceFile: ts.SourceFile): ResourceReplacement[] { + const replacements: ResourceReplacement[] = []; // Find all object literals. findAstNodes(null, sourceFile, @@ -33,9 +72,13 @@ export function replaceResources(sourceFile: ts.SourceFile): TransformOperation[ const key = _getContentOfKeyLiteral(node.name); if (key == 'templateUrl') { - const requireCall = _createRequireCall(_getResourceRequest(node.initializer, sourceFile)); + const resourcePath = _getResourceRequest(node.initializer, sourceFile); + const requireCall = _createRequireCall(resourcePath); const propAssign = ts.createPropertyAssignment('template', requireCall); - ops.push(new ReplaceNodeOperation(sourceFile, node, propAssign)); + replacements.push({ + resourcePaths: [resourcePath], + replaceNodeOperation: new ReplaceNodeOperation(sourceFile, node, propAssign) + }); } else if (key == 'styleUrls') { const arr = findAstNodes(node, sourceFile, ts.SyntaxKind.ArrayLiteralExpression, false); @@ -52,34 +95,14 @@ export function replaceResources(sourceFile: ts.SourceFile): TransformOperation[ ); const propAssign = ts.createPropertyAssignment('styles', requireArray); - ops.push(new ReplaceNodeOperation(sourceFile, node, propAssign)); + replacements.push({ + resourcePaths: stylePaths, + replaceNodeOperation: new ReplaceNodeOperation(sourceFile, node, propAssign) + }); } }); - if (ops.length > 0) { - // If we added a require call, we need to also add typings for it. - // The typings need to be compatible with node typings, but also work by themselves. - - // interface NodeRequire {(id: string): any;} - const nodeRequireInterface = ts.createInterfaceDeclaration([], [], 'NodeRequire', [], [], [ - ts.createCallSignature([], [ - ts.createParameter([], [], undefined, 'id', undefined, - ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ) - ], ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)) - ]); - - // declare var require: NodeRequire; - const varRequire = ts.createVariableStatement( - [ts.createToken(ts.SyntaxKind.DeclareKeyword)], - [ts.createVariableDeclaration('require', ts.createTypeReferenceNode('NodeRequire', []))] - ); - - ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), nodeRequireInterface)); - ops.push(new AddNodeOperation(sourceFile, getFirstNode(sourceFile), varRequire)); - } - - return ops; + return replacements; } function _getContentOfKeyLiteral(node?: ts.Node): string | null { diff --git a/packages/@ngtools/webpack/src/virtual_file_system_decorator.ts b/packages/@ngtools/webpack/src/virtual_file_system_decorator.ts index 7a8b982ae801..e0e473e88157 100644 --- a/packages/@ngtools/webpack/src/virtual_file_system_decorator.ts +++ b/packages/@ngtools/webpack/src/virtual_file_system_decorator.ts @@ -1,7 +1,11 @@ import { Stats } from 'fs'; -import { InputFileSystem, Callback } from './webpack'; +import { sep } from 'path'; + +import { InputFileSystem, NodeWatchFileSystemInterface, Callback } from './webpack'; import { WebpackCompilerHost } from './compiler_host'; +export const NodeWatchFileSystem: NodeWatchFileSystemInterface = require( + 'webpack/lib/node/NodeWatchFileSystem'); export class VirtualFileSystemDecorator implements InputFileSystem { constructor( @@ -26,6 +30,11 @@ export class VirtualFileSystemDecorator implements InputFileSystem { return null; } + getVirtualFilesPaths() { + return this._webpackCompilerHost.getNgFactoryPaths() + .map((fileName) => fileName.replace(/\//g, sep)); + } + stat(path: string, callback: Callback): void { const result = this._statSync(path); if (result) { @@ -89,3 +98,26 @@ export class VirtualFileSystemDecorator implements InputFileSystem { } } } + +export class VirtualWatchFileSystemDecorator extends NodeWatchFileSystem { + constructor(private _virtualInputFileSystem: VirtualFileSystemDecorator) { + super(_virtualInputFileSystem); + } + + watch(files: any, dirs: any, missing: any, startTime: any, options: any, callback: any, + callbackUndelayed: any) { + const newCallback = (err: any, filesModified: any, contextModified: any, missingModified: any, + fileTimestamps: { [k: string]: number }, contextTimestamps: { [k: string]: number }) => { + // 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[stats.path] = +stats.mtime); + callback(err, filesModified, contextModified, missingModified, fileTimestamps, + contextTimestamps); + }; + return super.watch(files, dirs, missing, startTime, options, newCallback, callbackUndelayed); + } +} diff --git a/packages/@ngtools/webpack/src/webpack.ts b/packages/@ngtools/webpack/src/webpack.ts index 32d23501c8a8..e6c9a580f69e 100644 --- a/packages/@ngtools/webpack/src/webpack.ts +++ b/packages/@ngtools/webpack/src/webpack.ts @@ -75,3 +75,10 @@ export interface InputFileSystem { readlinkSync(path: string): string; purge(changes?: string[] | string): void; } + +export interface NodeWatchFileSystemInterface { + inputFileSystem: InputFileSystem; + new(inputFileSystem: InputFileSystem): NodeWatchFileSystemInterface; + watch(files: any, dirs: any, missing: any, startTime: any, options: any, callback: any, + callbackUndelayed: any): any; +} diff --git a/tests/e2e/tests/build/rebuild-ngfactories.ts b/tests/e2e/tests/build/rebuild-ngfactories.ts new file mode 100644 index 000000000000..59969db5ed4b --- /dev/null +++ b/tests/e2e/tests/build/rebuild-ngfactories.ts @@ -0,0 +1,95 @@ +import { + killAllProcesses, + waitForAnyProcessOutputToMatch, + execAndWaitForOutputToMatch, +} from '../../utils/process'; +import { appendToFile, writeMultipleFiles, replaceInFile } from '../../utils/fs'; +import { request } from '../../utils/http'; +import { getGlobalVariable } from '../../utils/env'; + +const validBundleRegEx = /webpack: bundle is now VALID|webpack: Compiled successfully./; + +export default function () { + if (process.platform.startsWith('win')) { + return Promise.resolve(); + } + // Skip this in ejected tests. + if (getGlobalVariable('argv').eject) { + return Promise.resolve(); + } + + return execAndWaitForOutputToMatch('ng', ['serve', '--aot'], validBundleRegEx) + .then(() => writeMultipleFiles({ + 'src/app/app.component.css': ` + @import './imported-styles.css'; + body {background-color: #00f;} + `, + 'src/app/imported-styles.css': 'p {color: #f00;}', + })) + // Trigger a few rebuilds first. + // The AOT compiler is still optimizing rebuilds on the first rebuild. + .then(() => Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), + appendToFile('src/main.ts', 'console.log(1)\n') + ])) + .then(() => Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), + appendToFile('src/main.ts', 'console.log(1)\n') + ])) + // Check if html changes are built. + .then(() => Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), + appendToFile('src/app/app.component.html', '

HTML_REBUILD_STRING

') + ])) + .then(() => request('http://localhost:4200/main.bundle.js')) + .then((body) => { + if (!body.match(/HTML_REBUILD_STRING/)) { + throw new Error('Expected HTML_REBUILD_STRING but it wasn\'t in bundle.'); + } + }) + // Check if css changes are built. + .then(() => Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), + appendToFile('src/app/app.component.css', 'CSS_REBUILD_STRING {color: #f00;}') + ])) + .then(() => request('http://localhost:4200/main.bundle.js')) + .then((body) => { + if (!body.match(/CSS_REBUILD_STRING/)) { + throw new Error('Expected CSS_REBUILD_STRING but it wasn\'t in bundle.'); + } + }) + // Check if css dependency changes are built. + .then(() => Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), + appendToFile('src/app/imported-styles.css', 'CSS_DEP_REBUILD_STRING {color: #f00;}') + ])) + .then(() => request('http://localhost:4200/main.bundle.js')) + .then((body) => { + if (!body.match(/CSS_DEP_REBUILD_STRING/)) { + throw new Error('Expected CSS_DEP_REBUILD_STRING but it wasn\'t in bundle.'); + } + }) + .then(() => { + // Skip in non-nightly tests. Switch this check around when ng5 is out. + if (!getGlobalVariable('argv').nightly) { + return Promise.resolve(); + } + + // Check if component metadata changes are built. + return Promise.resolve() + .then(() => Promise.all([ + waitForAnyProcessOutputToMatch(validBundleRegEx, 10000), + replaceInFile('src/app/app.component.ts', 'app-root', 'app-root-FACTORY_REBUILD_STRING') + ])) + .then(() => request('http://localhost:4200/main.bundle.js')) + .then((body) => { + if (!body.match(/FACTORY_REBUILD_STRING/)) { + throw new Error('Expected FACTORY_REBUILD_STRING but it wasn\'t in bundle.'); + } + }); + }) + .then(() => killAllProcesses(), (err: any) => { + killAllProcesses(); + throw err; + }); +}