diff --git a/lib/template-compiler-plugin.js b/lib/template-compiler-plugin.js index 4beb2d80..3195b768 100644 --- a/lib/template-compiler-plugin.js +++ b/lib/template-compiler-plugin.js @@ -1,6 +1,5 @@ 'use strict'; -const fs = require('fs'); const path = require('path'); const utils = require('./utils'); const Filter = require('broccoli-persistent-filter'); @@ -40,9 +39,8 @@ class TemplateCompiler extends Filter { // TODO: do we need this? this.precompile = this.options.templateCompiler.precompile; - let { templateCompiler, plugins, EmberENV } = options; + let { templateCompiler, EmberENV } = options; - utils.registerPlugins(templateCompiler, plugins); utils.initializeEmberENV(templateCompiler, EmberENV); } @@ -50,23 +48,21 @@ class TemplateCompiler extends Filter { return __dirname; } - unregisterPlugins() { - let { templateCompiler, plugins } = this.options; - - utils.unregisterPlugins(templateCompiler, plugins); - } - - registeredASTPlugins() { - // This is a super obtuse way to get access to the plugins we've registered - // it also returns other plugins that are registered by ember itself. - let options = this.options.templateCompiler.compileOptions(); - return (options.plugins && options.plugins.ast) || []; - } - processString(string, relativePath) { let srcDir = this.inputPaths[0]; let srcName = path.join(srcDir, relativePath); try { + // we have to reverse these for reasons that are a bit bonkers. the initial + // version of this system used `registeredPlugin` from + // `ember-template-compiler.js` to set up these plugins (because Ember ~ 1.13 + // only had `registerPlugin`, and there was no way to pass plugins directly + // to the call to `compile`/`precompile`). calling `registerPlugin` + // unfortunately **inverted** the order of plugins (it essentially did + // `PLUGINS = [plugin, ...PLUGINS]`). + // + // sooooooo...... we are forced to maintain that **absolutely bonkers** ordering + let astPlugins = this.options.plugins ? [].concat(this.options.plugins.ast).reverse() : []; + let result = 'export default ' + utils.template(this.options.templateCompiler, stripBom(string), { @@ -76,10 +72,18 @@ class TemplateCompiler extends Filter { parseOptions: { srcName: srcName, }, + + // intentionally not using `plugins: this.options.plugins` here + // because if we do, Ember will mutate the shared plugins object (adding + // all of the built in AST transforms into plugins.ast, which breaks + // persistent caching) + plugins: { + ast: astPlugins, + }, }) + ';'; if (this.options.dependencyInvalidation) { - let plugins = pluginsWithDependencies(this.registeredASTPlugins()); + let plugins = pluginsWithDependencies(this.options.plugins.ast); let dependencies = []; for (let i = 0; i < plugins.length; i++) { let pluginDeps = plugins[i].getDependencies(relativePath); @@ -105,20 +109,16 @@ class TemplateCompiler extends Filter { return strippedOptions; } - _templateCompilerContents() { - if (this.options.templateCompilerPath) { - return fs.readFileSync(this.options.templateCompilerPath, { encoding: 'utf8' }); - } else { - return ''; - } - } - optionsHash() { if (!this._optionsHash) { + let templateCompilerCacheKey = utils.getTemplateCompilerCacheKey( + this.options.templateCompilerPath + ); + this._optionsHash = crypto .createHash('md5') .update(stringify(this._buildOptionsForHash()), 'utf8') - .update(stringify(this._templateCompilerContents()), 'utf8') + .update(templateCompilerCacheKey, 'utf8') .digest('hex'); } diff --git a/lib/utils.js b/lib/utils.js index e3d0fcf4..9aec3b15 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,11 +1,15 @@ 'use strict'; +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const hashForDep = require('hash-for-dep'); const debugGenerator = require('heimdalljs-logger'); const logger = debugGenerator('ember-cli-htmlbars:utils'); const addDependencyTracker = require('./addDependencyTracker'); +const vm = require('vm'); + +const TemplateCompilerCache = new Map(); const INLINE_PRECOMPILE_MODULES = Object.freeze({ 'ember-cli-htmlbars': 'hbs', @@ -79,18 +83,10 @@ function buildParalleizedBabelPlugin(pluginInfo, templateCompilerPath, isProduct function buildOptions(projectConfig, templateCompilerPath, pluginInfo) { let EmberENV = projectConfig.EmberENV || {}; - purgeModule(templateCompilerPath); - - // do a full clone of the EmberENV (it is guaranteed to be structured - // cloneable) to prevent ember-template-compiler.js from mutating - // the shared global config - let clonedEmberENV = JSON.parse(JSON.stringify(EmberENV)); - global.EmberENV = clonedEmberENV; // Needed for eval time feature flag checks - let htmlbarsOptions = { isHTMLBars: true, EmberENV: EmberENV, - templateCompiler: require(templateCompilerPath), + templateCompiler: getTemplateCompiler(templateCompilerPath, EmberENV), templateCompilerPath: templateCompilerPath, plugins: { @@ -102,57 +98,64 @@ function buildOptions(projectConfig, templateCompilerPath, pluginInfo) { pluginCacheKey: pluginInfo.cacheKeys, }; - purgeModule(templateCompilerPath); - - delete global.Ember; - delete global.EmberENV; - return htmlbarsOptions; } -function purgeModule(templateCompilerPath) { - // ensure we get a fresh templateCompilerModuleInstance per ember-addon - // instance NOTE: this is a quick hack, and will only work as long as - // templateCompilerPath is a single file bundle - // - // (╯°□°)╯︵ ɹǝqɯǝ - // - // we will also fix this in ember for future releases - - // Module will be cached in .parent.children as well. So deleting from require.cache alone is not sufficient. - let mod = require.cache[templateCompilerPath]; - if (mod && mod.parent) { - let index = mod.parent.children.indexOf(mod); - if (index >= 0) { - mod.parent.children.splice(index, 1); - } else { - throw new TypeError( - `ember-cli-htmlbars attempted to purge '${templateCompilerPath}' but something went wrong.` - ); - } - } +function getTemplateCompiler(templateCompilerPath, EmberENV = {}) { + let templateCompilerFullPath = require.resolve(templateCompilerPath); + let cacheData = TemplateCompilerCache.get(templateCompilerFullPath); - delete require.cache[templateCompilerPath]; -} + if (cacheData === undefined) { + let templateCompilerContents = fs.readFileSync(templateCompilerFullPath, { encoding: 'utf-8' }); + let templateCompilerCacheKey = crypto + .createHash('md5') + .update(templateCompilerContents) + .digest('hex'); -function registerPlugins(templateCompiler, plugins) { - if (plugins) { - for (let type in plugins) { - for (let i = 0, l = plugins[type].length; i < l; i++) { - templateCompiler.registerPlugin(type, plugins[type][i]); - } - } + cacheData = { + script: new vm.Script(templateCompilerContents, { + filename: templateCompilerPath, + }), + + templateCompilerCacheKey, + }; + + TemplateCompilerCache.set(templateCompilerFullPath, cacheData); } -} -function unregisterPlugins(templateCompiler, plugins) { - if (plugins) { - for (let type in plugins) { - for (let i = 0, l = plugins[type].length; i < l; i++) { - templateCompiler.unregisterPlugin(type, plugins[type][i]); - } - } + let { script } = cacheData; + + // do a full clone of the EmberENV (it is guaranteed to be structured + // cloneable) to prevent ember-template-compiler.js from mutating + // the shared global config + let clonedEmberENV = JSON.parse(JSON.stringify(EmberENV)); + + let sandbox = { + EmberENV: clonedEmberENV, + + // Older versions of ember-template-compiler (up until ember-source@3.1.0) + // eagerly access `setTimeout` without checking via `typeof` first + setTimeout, + clearTimeout, + + // fake the module into thinking we are running inside a Node context + module: { require, exports: {} }, + require, + }; + + // if we are running on a Node version _without_ a globalThis + // we must provide a `global` + // + // this is due to https://git.io/Jtb7s (Ember 3.27+) + if (typeof globalThis === 'undefined') { + sandbox.global = sandbox; } + + let context = vm.createContext(sandbox); + + script.runInContext(context); + + return context.module.exports; } function initializeEmberENV(templateCompiler, EmberENV) { @@ -196,11 +199,22 @@ function setup(pluginInfo, options) { let htmlbarsOptions = buildOptions(projectConfig, templateCompilerPath, pluginInfo); let { templateCompiler } = htmlbarsOptions; - registerPlugins(templateCompiler, { - ast: pluginInfo.plugins, - }); + let templatePrecompile = templateCompiler.precompile; + + let precompile = (template, options) => { + let plugins = pluginInfo.plugins || []; + // concat so we ensure we don't mutate the original plugins + // reverse to ensure that original AST plugin ordering is preserved + let astPlugins = [].concat(plugins).reverse(); + + options = options || {}; + options.plugins = { + ast: astPlugins, + }; + + return templatePrecompile(template, options); + }; - let { precompile } = templateCompiler; precompile.baseDir = () => path.resolve(__dirname, '..'); let cacheKey; @@ -220,10 +234,22 @@ function setup(pluginInfo, options) { return plugin; } -function makeCacheKey(templateCompilerPath, pluginInfo, extra) { +function getTemplateCompilerCacheKey(templateCompilerPath) { let templateCompilerFullPath = require.resolve(templateCompilerPath); - let templateCompilerCacheKey = fs.readFileSync(templateCompilerFullPath, { encoding: 'utf-8' }); + let cacheData = TemplateCompilerCache.get(templateCompilerFullPath); + + if (cacheData === undefined) { + getTemplateCompiler(templateCompilerFullPath); + cacheData = TemplateCompilerCache.get(templateCompilerFullPath); + } + + return cacheData.templateCompilerCacheKey; +} + +function makeCacheKey(templateCompilerPath, pluginInfo, extra) { + let templateCompilerCacheKey = getTemplateCompilerCacheKey(templateCompilerPath); let cacheItems = [templateCompilerCacheKey, extra].concat(pluginInfo.cacheKeys.sort()); + // extra may be undefined return cacheItems.filter(Boolean).join('|'); } @@ -288,9 +314,6 @@ function setupPlugins(wrappers) { module.exports = { buildOptions, - purgeModule, - registerPlugins, - unregisterPlugins, initializeEmberENV, template, setup, @@ -299,4 +322,6 @@ module.exports = { isColocatedBabelPluginRegistered, isInlinePrecompileBabelPluginRegistered, buildParalleizedBabelPlugin, + getTemplateCompiler, + getTemplateCompilerCacheKey, }; diff --git a/node-tests/ast_plugins_test.js b/node-tests/ast_plugins_test.js index 658b41c7..5edc0ddc 100644 --- a/node-tests/ast_plugins_test.js +++ b/node-tests/ast_plugins_test.js @@ -10,7 +10,6 @@ const { createTempDir, createBuilder } = require('broccoli-test-helper'); const fixturify = require('fixturify'); const addDependencyTracker = require('../lib/addDependencyTracker'); const templateCompiler = require('ember-source/dist/ember-template-compiler.js'); -const CANNOT_UNREGISTER_PLUGINS = !templateCompiler.unregisterPlugin; describe('AST plugins', function() { const they = it; @@ -18,6 +17,12 @@ describe('AST plugins', function() { let input, output, builder, tree, htmlbarsOptions; + let clearTreeCache = co.wrap(function* clearTreeCache(tree) { + if (tree && tree.processor.processor._cache) { + yield tree.processor.processor._cache.clear(); + } + }); + beforeEach( co.wrap(function*() { rewriterCallCount = 0; @@ -26,18 +31,14 @@ describe('AST plugins', function() { htmlbarsOptions = { isHTMLBars: true, templateCompiler: templateCompiler, + templateCompilerPath: require.resolve('ember-source/dist/ember-template-compiler.js'), }; }) ); afterEach( co.wrap(function*() { - if (tree) { - tree.unregisterPlugins(); - if (tree.processor.processor._cache) { - yield tree.processor.processor._cache.clear(); - } - } + yield clearTreeCache(tree); if (builder) { builder.cleanup(); @@ -103,9 +104,6 @@ describe('AST plugins', function() { they( 'are accepted and used.', co.wrap(function*() { - if (CANNOT_UNREGISTER_PLUGINS) { - this.skip(); - } htmlbarsOptions.plugins = { ast: [DivRewriter], }; @@ -125,9 +123,6 @@ describe('AST plugins', function() { they( 'will bust the hot cache if the dependency changes.', co.wrap(function*() { - if (CANNOT_UNREGISTER_PLUGINS) { - this.skip(); - } Object.assign(htmlbarsOptions, { plugins: { ast: [DivRewriter], @@ -184,9 +179,6 @@ describe('AST plugins', function() { they( 'will bust the persistent cache if the template cache key changes.', co.wrap(function*() { - if (CANNOT_UNREGISTER_PLUGINS) { - this.skip(); - } Object.assign(htmlbarsOptions, { plugins: { ast: [DivRewriter], @@ -194,55 +186,60 @@ describe('AST plugins', function() { dependencyInvalidation: true, }); - let firstTree = new TemplateCompiler(input.path(), htmlbarsOptions); + let firstTree, secondTree, thirdTree; try { - output = createBuilder(firstTree); - yield output.build(); - - let templateOutput = output.readText('template.js'); - assert.ok(!templateOutput.match(/div/)); - assert.ok(templateOutput.match(/my-custom-element/)); - assert.strictEqual(rewriterCallCount, 1); - } finally { - yield output.dispose(); - firstTree.unregisterPlugins(); - } - - // The state didn't change. the output should be cached - // and the rewriter shouldn't be invoked. - let secondTree = new TemplateCompiler(input.path(), htmlbarsOptions); - try { - let output = createBuilder(secondTree); - yield output.build(); - assert.deepStrictEqual(output.changes()['template.js'], 'create'); - // the "new" file is read from cache. - let templateOutput = output.readText('template.js'); - assert.ok(!templateOutput.match(/div/)); - assert.ok(templateOutput.match(/my-custom-element/)); - assert.strictEqual(rewriterCallCount, 1); - } finally { - yield output.dispose(); - secondTree.unregisterPlugins(); - } + firstTree = new TemplateCompiler(input.path(), htmlbarsOptions); + + try { + output = createBuilder(firstTree); + yield output.build(); + + let templateOutput = output.readText('template.js'); + assert.ok(!templateOutput.match(/div/)); + assert.ok(templateOutput.match(/my-custom-element/)); + assert.strictEqual(rewriterCallCount, 1); + } finally { + yield output.dispose(); + } - // The state changes. the cache key updates and the template - // should be recompiled. - input.write({ - 'template.tagname': 'MyChangedElement', - }); + // The state didn't change. the output should be cached + // and the rewriter shouldn't be invoked. + secondTree = new TemplateCompiler(input.path(), htmlbarsOptions); + try { + let output = createBuilder(secondTree); + yield output.build(); + assert.deepStrictEqual(output.changes()['template.js'], 'create'); + // the "new" file is read from cache. + let templateOutput = output.readText('template.js'); + assert.ok(!templateOutput.match(/div/)); + assert.ok(templateOutput.match(/my-custom-element/)); + assert.strictEqual(rewriterCallCount, 1); + } finally { + yield output.dispose(); + } - let thirdTree = new TemplateCompiler(input.path(), htmlbarsOptions); - try { - let output = createBuilder(thirdTree); - yield output.build(); - let templateOutput = output.readText('template.js'); - assert.strictEqual(rewriterCallCount, 2); - assert.ok(templateOutput.match(/my-changed-element/)); - assert.strictEqual(rewriterCallCount, 2); + // The state changes. the cache key updates and the template + // should be recompiled. + input.write({ + 'template.tagname': 'MyChangedElement', + }); + + thirdTree = new TemplateCompiler(input.path(), htmlbarsOptions); + try { + let output = createBuilder(thirdTree); + yield output.build(); + let templateOutput = output.readText('template.js'); + assert.strictEqual(rewriterCallCount, 2); + assert.ok(templateOutput.match(/my-changed-element/)); + assert.strictEqual(rewriterCallCount, 2); + } finally { + yield output.dispose(); + } } finally { - yield output.dispose(); - thirdTree.unregisterPlugins(); + clearTreeCache(firstTree); + clearTreeCache(secondTree); + clearTreeCache(thirdTree); } }) ); diff --git a/node-tests/purge-module-test.js b/node-tests/purge-module-test.js deleted file mode 100644 index 4d247cd5..00000000 --- a/node-tests/purge-module-test.js +++ /dev/null @@ -1,38 +0,0 @@ -'use strict'; - -const purgeModule = require('../lib/utils').purgeModule; -const expect = require('chai').expect; - -describe('purgeModule', function() { - const FIXTURE_COMPILER_PATH = require.resolve('./fixtures/compiler'); - - it('it works correctly', function() { - expect(purgeModule('asdfasdfasdfaf-unknown-file')).to.eql(undefined); - - expect(require.cache[FIXTURE_COMPILER_PATH]).to.eql(undefined); - - require(FIXTURE_COMPILER_PATH); - - const mod = require.cache[FIXTURE_COMPILER_PATH]; - - expect(mod.parent).to.eql(module); - expect(mod.parent.children).to.include(mod); - - purgeModule(FIXTURE_COMPILER_PATH); - - expect(require.cache[FIXTURE_COMPILER_PATH]).to.eql(undefined); - expect(mod.parent.children).to.not.include(mod); - - require(FIXTURE_COMPILER_PATH); - - const freshModule = require.cache[FIXTURE_COMPILER_PATH]; - - expect(freshModule.parent).to.eql(module); - expect(freshModule.parent.children).to.include(freshModule); - - purgeModule(FIXTURE_COMPILER_PATH); - - expect(require.cache[FIXTURE_COMPILER_PATH]).to.eql(undefined); - expect(freshModule.parent.children).to.not.include(mod); - }); -}); diff --git a/node-tests/template_compiler_test.js b/node-tests/template_compiler_test.js index d320d3de..9336ab4d 100644 --- a/node-tests/template_compiler_test.js +++ b/node-tests/template_compiler_test.js @@ -38,6 +38,7 @@ describe('TemplateCompiler', function() { htmlbarsOptions = { isHTMLBars: true, templateCompiler: require('ember-source/dist/ember-template-compiler.js'), + templateCompilerPath: require.resolve('ember-source/dist/ember-template-compiler.js'), }; htmlbarsPrecompile = htmlbarsOptions.templateCompiler.precompile; @@ -82,12 +83,8 @@ describe('TemplateCompiler', function() { let tree = new TemplateCompiler(input.path(), htmlbarsOptions); - try { - output = createBuilder(tree); - await output.build(); - } finally { - tree.unregisterPlugins(); - } + output = createBuilder(tree); + await output.build(); let expected = `export default Ember.HTMLBars.template(${htmlbarsPrecompile(source, { moduleName: 'template.hbs', @@ -125,12 +122,8 @@ describe('TemplateCompiler', function() { let tree = new TemplateCompiler(input.path(), htmlbarsOptions); - try { - output = createBuilder(tree); - await output.build(); - } finally { - tree.unregisterPlugins(); - } + output = createBuilder(tree); + await output.build(); assert.ok(wasProduction); });