diff --git a/.gitignore b/.gitignore index e1328041..4ca53526 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules/ /dist/ +/coverage/ npm-debug.*.log diff --git a/index.js b/index.js index a8a87956..193fbc9a 100644 --- a/index.js +++ b/index.js @@ -97,8 +97,22 @@ class HtmlWebpackPlugin { // Clear the cache once a new HtmlWebpackPlugin is added childCompiler.clearCache(compiler); - compiler.hooks.compile.tap('HtmlWebpackPlugin', () => { + // Register all HtmlWebpackPlugins instances at the child compiler + compiler.hooks.thisCompilation.tap('HtmlWebpackPlugin', (compilation) => { + // Clear the cache if the child compiler is outdated + if (childCompiler.hasOutDatedTemplateCache(compilation)) { + childCompiler.clearCache(compiler); + } + // Add this instances template to the child compiler childCompiler.addTemplateToCompiler(compiler, this.options.template); + // Add file dependencies of child compiler to parent compiler + // to keep them watched even if we get the result from the cache + compilation.hooks.additionalChunkAssets.tap('HtmlWebpackPlugin', () => { + const childCompilerDependencies = childCompiler.getFileDependencies(compiler); + childCompilerDependencies.forEach(fileDependency => { + compilation.compilationDependencies.add(fileDependency); + }); + }); }); // setup hooks for third party plugins @@ -111,12 +125,13 @@ class HtmlWebpackPlugin { compilation.errors.push(prettyError(err, compiler.context).toString()); return { content: self.options.showErrors ? prettyError(err, compiler.context).toJsonHtml() : 'ERROR', - outputName: self.options.filename + outputName: self.options.filename, + hash: '' }; }) .then(compilationResult => { // If the compilation change didnt change the cache is valid - isCompilationCached = compilationResult.hash && self.childCompilerHash === compilationResult.hash; + isCompilationCached = Boolean(compilationResult.hash) && self.childCompilerHash === compilationResult.hash; self.childCompilerHash = compilationResult.hash; self.childCompilationOutputName = compilationResult.outputName; callback(); @@ -131,9 +146,6 @@ class HtmlWebpackPlugin { * @param {() => void} callback */ (compilation, callback) => { - // Clear the childCompilerCache - childCompiler.clearCache(compiler); - // Get all entry point names for this html file const entryNames = Array.from(compilation.entrypoints.keys()); const filteredEntryNames = self.filterChunks(entryNames, self.options.chunks, self.options.excludeChunks); diff --git a/lib/compiler.js b/lib/compiler.js index d9a871ed..fa099bb2 100644 --- a/lib/compiler.js +++ b/lib/compiler.js @@ -33,36 +33,61 @@ class HtmlWebpackChildCompiler { */ this.compilationPromise; /** - * @type {Date} + * @type {number} */ - this.compilationStarted; + this.compilationStartedTimestamp; + /** + * @type {number} + */ + this.compilationEndedTimestamp; /** * All file dependencies of the child compiler * @type {string[]} */ this.fileDependencies = []; + /** + * Store if the cache was already verified for the given compilation + * @type {WeakMap}} + */ + this.cacheVerifiedForCompilation = new WeakMap(); } /** * Add a templatePath to the child compiler * The given template will be compiled by `compileTemplates` * @param {string} template - The webpack path to the template e.g. `'!!html-loader!index.html'` + * @returns {boolean} true if the template is new */ addTemplate (template) { const templateId = this.templates.indexOf(template); // Don't add the template to the compiler if a similar template was already added if (templateId !== -1) { - return templateId; + return false; } // A child compiler can compile only once // throw an error if a new template is added after the compilation started - if (this.compilationPromise) { + if (this.isCompiling()) { throw new Error('New templates can only be added before `compileTemplates` was called.'); } // Add the template to the childCompiler - const newTemplateId = this.templates.length; this.templates.push(template); - return newTemplateId; + // Mark the cache invalid + return true; + } + + /** + * Returns true if the childCompiler is currently compiling + * @retuns {boolean} + */ + isCompiling () { + return !this.didCompile() && this.compilationStartedTimestamp !== undefined; + } + + /** + * Returns true if the childCOmpiler is done compiling + */ + didCompile () { + return this.compilationEndedTimestamp !== undefined; } /** @@ -116,7 +141,7 @@ class HtmlWebpackChildCompiler { new SingleEntryPlugin(childCompiler.context, template, `HtmlWebpackPlugin_${index}`).apply(childCompiler); }); - this.compilationStarted = new Date(); + this.compilationStartedTimestamp = new Date().getTime(); this.compilationPromise = new Promise((resolve, reject) => { childCompiler.runAsChild((err, entries, childCompilation) => { // Extract templates @@ -152,12 +177,44 @@ class HtmlWebpackChildCompiler { entry: entries[entryIndex] }; }); + this.compilationEndedTimestamp = new Date().getTime(); resolve(result); }); }); return this.compilationPromise; } + + /** + * Returns `false` if any template file depenendencies has changed + * for the given main compilation + * + * @param {WebpackCompilation} mainCompilation + * @returns {boolean} + */ + hasOutDatedTemplateCache (mainCompilation) { + // Check if cache validation was already computed + const isCacheValid = this.cacheVerifiedForCompilation.get(mainCompilation); + if (isCacheValid !== undefined) { + return isCacheValid; + } + // If the compilation was never run there is no invalid cache + if (!this.compilationStartedTimestamp) { + this.cacheVerifiedForCompilation.set(mainCompilation, false); + return false; + } + // Check if any dependent file was changed after the last compilation + const fileTimestamps = mainCompilation.fileTimestamps; + const isCacheOutOfDate = this.fileDependencies.some((fileDependency) => { + const timestamp = fileTimestamps.get(fileDependency); + // If the timestamp is not known the file is new + // If the timestamp is larger then the file has changed + // Otherwise the file is still the same + return !timestamp || timestamp > this.compilationStartedTimestamp; + }); + this.cacheVerifiedForCompilation.set(mainCompilation, isCacheOutOfDate); + return isCacheOutOfDate; + } } /** @@ -214,10 +271,13 @@ const childCompilerCache = new WeakMap(); * @param {WebpackCompiler} mainCompiler */ function getChildCompiler (mainCompiler) { - if (!childCompilerCache[mainCompiler]) { - childCompilerCache[mainCompiler] = new HtmlWebpackChildCompiler(); + const cachedChildCompiler = childCompilerCache.get(mainCompiler); + if (cachedChildCompiler) { + return cachedChildCompiler; } - return childCompilerCache[mainCompiler]; + const newCompiler = new HtmlWebpackChildCompiler(); + childCompilerCache.set(mainCompiler, newCompiler); + return newCompiler; } /** @@ -226,7 +286,12 @@ function getChildCompiler (mainCompiler) { * @param {WebpackCompiler} mainCompiler */ function clearCache (mainCompiler) { - delete (childCompilerCache[mainCompiler]); + const childCompiler = getChildCompiler(mainCompiler); + // If this childCompiler was already used + // remove the entire childCompiler from the cache + if (childCompiler.isCompiling() || childCompiler.didCompile()) { + childCompilerCache.delete(mainCompiler); + } } /** @@ -235,7 +300,11 @@ function clearCache (mainCompiler) { * @param {string} templatePath */ function addTemplateToCompiler (mainCompiler, templatePath) { - getChildCompiler(mainCompiler).addTemplate(templatePath); + const childCompiler = getChildCompiler(mainCompiler); + const isNew = childCompiler.addTemplate(templatePath); + if (isNew) { + clearCache(mainCompiler); + } } /** @@ -252,6 +321,7 @@ function addTemplateToCompiler (mainCompiler, templatePath) { function compileTemplate (templatePath, outputFilename, mainCompilation) { const childCompiler = getChildCompiler(mainCompilation.compiler); return childCompiler.compileTemplates(mainCompilation).then((compiledTemplates) => { + if (!compiledTemplates[templatePath]) console.log(Object.keys(compiledTemplates), templatePath); const compiledTemplate = compiledTemplates[templatePath]; // Replace [hash] placeholders in filename const outputName = mainCompilation.mainTemplate.hooks.assetPath.call(outputFilename, { @@ -269,8 +339,32 @@ function compileTemplate (templatePath, outputFilename, mainCompilation) { }); } +/** + * Returns false if the cache is not valid anymore + * + * @param {WebpackCompilation} compilation + * @returns {boolean} + */ +function hasOutDatedTemplateCache (compilation) { + const childCompiler = childCompilerCache.get(compilation.compiler); + return childCompiler ? childCompiler.hasOutDatedTemplateCache(compilation) : false; +} + +/** + * Return all file dependencies of the last child compilation + * + * @param {WebpackCompiler} compiler + * @returns {Array} + */ +function getFileDependencies (compiler) { + const childCompiler = getChildCompiler(compiler); + return childCompiler.fileDependencies; +} + module.exports = { addTemplateToCompiler, compileTemplate, - clearCache + hasOutDatedTemplateCache, + clearCache, + getFileDependencies }; diff --git a/lib/loader.js b/lib/loader.js index 3fecc64b..01c52cd9 100644 --- a/lib/loader.js +++ b/lib/loader.js @@ -5,26 +5,24 @@ const _ = require('lodash'); const loaderUtils = require('loader-utils'); module.exports = function (source) { - if (this.cacheable) { - this.cacheable(); - } + // Get templating options + const options = this.query !== '' ? loaderUtils.getOptions(this) : {}; + const force = options.force || false; + const allLoadersButThisOne = this.loaders.filter(function (loader) { - // Loader API changed from `loader.module` to `loader.normal` in Webpack 2. - return (loader.module || loader.normal) !== module.exports; + return loader.normal !== module.exports; }); - // This loader shouldn't kick in if there is any other loader - if (allLoadersButThisOne.length > 0) { + // This loader shouldn't kick in if there is any other loader (unless it's explicitly enforced) + if (allLoadersButThisOne.length > 0 && !force) { return source; } - // Skip .js files - if (/\.js$/.test(this.resourcePath)) { + // Skip .js files (unless it's explicitly enforced) + if (/\.js$/.test(this.resourcePath) && !force) { return source; } // The following part renders the template with lodash as aminimalistic loader // - // Get templating options - const options = this.query !== '' ? loaderUtils.getOptions(this) : {}; const template = _.template(source, _.defaults(options, { interpolate: /<%=([\s\S]+?)%>/g, variable: 'data' })); // Require !!lodash - using !! will disable all loaders (e.g. babel) return 'var _ = require(' + loaderUtils.stringifyRequest(this, '!!' + require.resolve('lodash')) + ');' + diff --git a/package.json b/package.json index cc72fc17..dd232467 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "posttest": "tsc", "commit": "git-cz", "build-examples": "node examples/build-examples.js", - "test": "jest --runInBand", + "test": "jest --runInBand --verbose --coverage", "test-watch": "jest --runInBand --watch", "release": "standard-version" }, @@ -44,9 +44,9 @@ "typescript": "2.9.1", "underscore-template-loader": "^0.7.3", "url-loader": "^0.5.7", - "webpack": "4.8.3", + "webpack": "4.1.0", "webpack-cli": "2.0.12", - "webpack-recompilation-simulator": "^1.3.0" + "webpack-recompilation-simulator": "^3.0.0" }, "dependencies": { "@types/tapable": "1.0.2", diff --git a/spec/basic.spec.js b/spec/basic.spec.js index 2a9a0445..f8fc7e63 100644 --- a/spec/basic.spec.js +++ b/spec/basic.spec.js @@ -33,6 +33,7 @@ var OUTPUT_DIR = path.resolve(__dirname, '../dist/basic-spec'); jest.setTimeout(30000); process.on('unhandledRejection', r => console.log(r)); +process.traceDeprecation = true; function testHtmlPlugin (webpackConfig, expectedResults, outputFile, done, expectErrors, expectWarnings) { outputFile = outputFile || 'index.html'; diff --git a/spec/caching.spec.js b/spec/caching.spec.js index 94c4e04d..43f7fbec 100644 --- a/spec/caching.spec.js +++ b/spec/caching.spec.js @@ -10,36 +10,74 @@ var webpack = require('webpack'); var rimraf = require('rimraf'); var WebpackRecompilationSimulator = require('webpack-recompilation-simulator'); var HtmlWebpackPlugin = require('../index.js'); -var webpackMajorVersion = require('webpack/package.json').version.split('.')[0]; var OUTPUT_DIR = path.join(__dirname, '../dist/caching-spec'); jest.setTimeout(30000); process.on('unhandledRejection', r => console.log(r)); +process.traceDeprecation = true; function setUpCompiler (htmlWebpackPlugin) { jest.spyOn(htmlWebpackPlugin, 'evaluateCompilationResult'); var webpackConfig = { + stats: {all: true}, + // Caching works only in development + mode: 'development', entry: path.join(__dirname, 'fixtures/index.js'), + module: { + rules: [ + { + test: /\.html$/, + loader: require.resolve('../lib/loader.js'), + options: { + force: true + } + } + ] + }, output: { path: OUTPUT_DIR, filename: 'index_bundle.js' }, plugins: [htmlWebpackPlugin] }; - if (Number(webpackMajorVersion) >= 4) { - webpackConfig.mode = 'development'; - } var compiler = new WebpackRecompilationSimulator(webpack(webpackConfig)); return compiler; } -function getCompiledModuleCount (statsJson) { - return statsJson.modules.filter(function (webpackModule) { +function getCompiledModules (statsJson) { + const builtModules = statsJson.modules.filter(function (webpackModule) { return webpackModule.built; - }).length + statsJson.children.reduce(function (sum, childCompilationStats) { - return sum + getCompiledModuleCount(childCompilationStats); - }, 0); + }).map((webpackModule) => { + return module.userRequest; + }); + statsJson.children.forEach((childCompilationStats) => { + const builtChildModules = getCompiledModules(childCompilationStats); + Array.prototype.push.apply(builtModules, builtChildModules); + }); + return builtModules; +} + +function getCompiledModuleCount (statsJson) { + return getCompiledModules(statsJson).length; +} + +function expectNoErrors (stats) { + const errors = { + main: stats.compilation.errors, + childCompilation: [] + }; + stats.compilation.children.forEach((child) => { + Array.prototype.push.apply(errors.childCompilation, child.errors); + }); + if (errors.main.length) { + errors.main.forEach((error) => { + console.log('Error => ', error); + }); + console.dir(stats.toJson({errorDetails: true, moduleTrace: true}), { depth: 5 }); + } + expect(errors.main).toEqual([]); + expect(errors.childCompilation).toEqual([]); } describe('HtmlWebpackPluginCaching', function () { @@ -54,6 +92,7 @@ describe('HtmlWebpackPluginCaching', function () { }); var childCompilerHash; var compiler = setUpCompiler(htmlWebpackPlugin); + compiler.addTestFile(path.join(__dirname, 'fixtures/index.js')); compiler.run() // Change the template file and compile again .then(function () { @@ -61,9 +100,11 @@ describe('HtmlWebpackPluginCaching', function () { return compiler.run(); }) .then(function (stats) { + // Expect no errors: + expectNoErrors(stats); // Verify that no file was built - expect(getCompiledModuleCount(stats.toJson())) - .toBe(0); + expect(getCompiledModules(stats.toJson())) + .toEqual([]); // Verify that the html was processed only during the inital build expect(htmlWebpackPlugin.evaluateCompilationResult.mock.calls.length) .toBe(1); @@ -78,6 +119,7 @@ describe('HtmlWebpackPluginCaching', function () { var htmlWebpackPlugin = new HtmlWebpackPlugin(); var compiler = setUpCompiler(htmlWebpackPlugin); var childCompilerHash; + compiler.addTestFile(path.join(__dirname, 'fixtures/index.js')); compiler.run() // Change a js file and compile again .then(function () { @@ -86,6 +128,8 @@ describe('HtmlWebpackPluginCaching', function () { return compiler.run(); }) .then(function (stats) { + // Expect no errors: + expectNoErrors(stats); // Verify that only one file was built expect(getCompiledModuleCount(stats.toJson())) .toBe(1); @@ -105,6 +149,7 @@ describe('HtmlWebpackPluginCaching', function () { }); var childCompilerHash; var compiler = setUpCompiler(htmlWebpackPlugin); + compiler.addTestFile(path.join(__dirname, 'fixtures/index.js')); compiler.run() // Change a js file and compile again .then(function () { @@ -113,6 +158,8 @@ describe('HtmlWebpackPluginCaching', function () { return compiler.run(); }) .then(function (stats) { + // Expect no errors: + expectNoErrors(stats); // Verify that only one file was built expect(getCompiledModuleCount(stats.toJson())) .toBe(1); @@ -133,6 +180,7 @@ describe('HtmlWebpackPluginCaching', function () { }); var childCompilerHash; var compiler = setUpCompiler(htmlWebpackPlugin); + compiler.addTestFile(template); compiler.run() // Change the template file and compile again .then(function () { @@ -141,6 +189,8 @@ describe('HtmlWebpackPluginCaching', function () { return compiler.run(); }) .then(function (stats) { + // Expect no errors: + expectNoErrors(stats); // Verify that only one file was built expect(getCompiledModuleCount(stats.toJson())) .toBe(1); @@ -153,4 +203,49 @@ describe('HtmlWebpackPluginCaching', function () { }) .then(done); }); + + it('should keep watching the webpack html if only a js file was changed', function (done) { + var template = path.join(__dirname, 'fixtures/plain.html'); + const jsFile = path.join(__dirname, 'fixtures/index.js'); + var htmlWebpackPlugin = new HtmlWebpackPlugin({ + template: template + }); + var compiler = setUpCompiler(htmlWebpackPlugin); + compiler.addTestFile(template); + compiler.addTestFile(jsFile); + // Build the template file for the first time + compiler.startWatching() + // Change the template file (second build) + .then(() => { + compiler.simulateFileChange(template, {footer: ''}); + return compiler.waitForWatchRunComplete(); + }) + // Change js + .then(() => { + compiler.simulateFileChange(jsFile, {footer: '// 1'}); + return compiler.waitForWatchRunComplete(); + }) + // Change js + .then(() => { + compiler.simulateFileChange(jsFile, {footer: '// 2'}); + return compiler.waitForWatchRunComplete(); + }) + // Change js + .then(() => { + compiler.simulateFileChange(jsFile, {footer: '// 3'}); + return compiler.waitForWatchRunComplete(); + }) + // Change the template file (third build) + .then(() => { + compiler.simulateFileChange(template, {footer: ''}); + return compiler.waitForWatchRunComplete(); + }) + .then(() => { + // Verify that the html was processed trice + expect(htmlWebpackPlugin.evaluateCompilationResult.mock.calls.length) + .toBe(3); + }) + .then(() => compiler.stopWatching()) + .then(done); + }); }); diff --git a/spec/example.spec.js b/spec/example.spec.js index 230b3ba9..c470a98b 100644 --- a/spec/example.spec.js +++ b/spec/example.spec.js @@ -15,6 +15,7 @@ var webpackMajorVersion = require('webpack/package.json').version.split('.')[0]; var OUTPUT_DIR = path.resolve(__dirname, '../dist'); jest.setTimeout(30000); +process.traceDeprecation = true; function runExample (exampleName, done) { var examplePath = path.resolve(__dirname, '..', 'examples', exampleName); diff --git a/tsconfig.json b/tsconfig.json index ff013dfd..112ef7cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,7 @@ "node_modules", "spec", "examples", - "dist" + "dist", + "coverage" ] }