Repository: ember-cli/ember-cli
Pull request #9543 (2021-05-24 16:56:57)

Add ability to specify a custom `ember-addon.perBundleAddonCacheUtil` utility
This PR adds the ability to specify a custom configuration in the project's `package.json` via the `ember-addon.perBundleAddonCacheUtil` configuration.

If a consuming application specifies a relative path to a custom utility via the `ember-addon.perBundleAddonCacheUtil` configuration, we prefer the custom implementation provided by the consumer; however, the default implementation here is to indicate if the original addon entry point has the `allowCachingPerBundle` flag set either on itself or on its prototype.


Patch:
diff --git a/lib/models/package-info-cache/package-info.js b/lib/models/package-info-cache/package-info.js
index 66f35d3d5..4ec686024 100644
--- a/lib/models/package-info-cache/package-info.js
+++ b/lib/models/package-info-cache/package-info.js
@@ -262,7 +262,7 @@ class PackageInfo {
    * module this represents has lazyLoading enabled, false otherwise.
    */
   isForLazyEngine() {
-    return this.isForEngine() && isLazyEngine(require(this.addonMainPath));
+    return this.isForEngine() && isLazyEngine(this._getAddonEntryPoint());
   }
 
   /**
@@ -435,14 +435,7 @@ class PackageInfo {
       return this.addonConstructor;
     }
 
-    if (!this.addonMainPath) {
-      throw new Error(`${this.pkg.name} at ${this.realPath} is missing its addon main file`);
-    }
-
-    // Load the addon.
-    // TODO: Future work - allow a time budget for loading each addon and warn
-    // or error for those that take too long.
-    let module = require(this.addonMainPath);
+    let module = this._getAddonEntryPoint();
     let mainDir = path.dirname(this.addonMainPath);
 
     let ctor;
@@ -465,20 +458,6 @@ class PackageInfo {
     return (this.addonConstructor = ctor);
   }
 
-  /**
-   * Indicate if a constructor (function or class) has the 'allowCachingPerBundle' flag
-   * set either on itself or on its prototype. Depending on how addon modules or constructor
-   * functions are set up, it could be in either. Needed for per-bundle addon caching.
-   *
-   * @method allowCachingPerBundle
-   * @param {Function|Class} ctor
-   * @return {Boolean} true if the given constructor function or class supports caching per bundle, false otherwise
-   */
-  allowCachingPerBundle(ctor) {
-    let val = !!ctor && (ctor.allowCachingPerBundle || ctor.prototype.allowCachingPerBundle);
-    return val;
-  }
-
   /**
    * Construct an addon instance.
    *
@@ -530,10 +509,14 @@ class PackageInfo {
    * @return {Object} the constructed instance of the addon
    */
   getAddonInstance(parent, project) {
-    let ctor = this.getAddonConstructor();
+    let addonEntryPointModule = this._getAddonEntryPoint();
     let addonInstance;
 
-    if (PerBundleAddonCache.isEnabled() && this.allowCachingPerBundle(ctor)) {
+    if (
+      PerBundleAddonCache.isEnabled() &&
+      project &&
+      project.perBundleAddonCache.allowCachingPerBundle(addonEntryPointModule)
+    ) {
       addonInstance = project.perBundleAddonCache.getAddonInstance(parent, this);
     } else {
       addonInstance = this.constructAddonInstance(parent, project);
@@ -559,6 +542,24 @@ class PackageInfo {
       addonInstance.addons = [];
     }
   }
+
+  /**
+   * Gets the addon entry point
+   *
+   * @method _getAddonEntryPoint
+   * @return {Object|Function} The exported addon entry point
+   * @private
+   */
+  _getAddonEntryPoint() {
+    if (!this.addonMainPath) {
+      throw new Error(`${this.pkg.name} at ${this.realPath} is missing its addon main file`);
+    }
+
+    // Load the addon.
+    // TODO: Future work - allow a time budget for loading each addon and warn
+    // or error for those that take too long.
+    return require(this.addonMainPath);
+  }
 }
 
 module.exports = PackageInfo;
diff --git a/lib/models/per-bundle-addon-cache/index.js b/lib/models/per-bundle-addon-cache/index.js
index b3f05b5fa..064a0cc74 100644
--- a/lib/models/per-bundle-addon-cache/index.js
+++ b/lib/models/per-bundle-addon-cache/index.js
@@ -1,10 +1,48 @@
 'use strict';
 
+const fs = require('fs');
+const path = require('path');
 const isLazyEngine = require('../../utilities/is-lazy-engine');
 const { getAddonProxy } = require('./addon-proxy');
 const logger = require('heimdalljs-logger')('ember-cli:per-bundle-addon-cache');
 const { TARGET_INSTANCE } = require('./target-instance');
 
+function defaultAllowCachingPerBundle({ addonEntryPointModule }) {
+  return (
+    addonEntryPointModule.allowCachingPerBundle ||
+    (addonEntryPointModule.prototype && addonEntryPointModule.prototype.allowCachingPerBundle)
+  );
+}
+
+/**
+ * Resolves the perBundleAddonCacheUtil; this prefers the custom provided version by
+ * the consuming application, and defaults to an internal implementation here.
+ *
+ * @method resolvePerBundleAddonCacheUtil
+ * @param {Project} project
+ * @return {{allowCachingPerBundle: Function}}
+ */
+function resolvePerBundleAddonCacheUtil(project) {
+  const relativePathToUtil =
+    project.pkg && project.pkg['ember-addon'] && project.pkg['ember-addon'].perBundleAddonCacheUtil;
+
+  if (typeof relativePathToUtil === 'string') {
+    const absolutePathToUtil = path.resolve(project.root, relativePathToUtil);
+
+    if (!fs.existsSync(absolutePathToUtil)) {
+      throw new Error(
+        `[ember-cli] the provided \`${relativePathToUtil}\` for \`ember-addon.perBundleAddonCacheUtil\` does not exist`
+      );
+    }
+
+    return require(absolutePathToUtil);
+  }
+
+  return {
+    allowCachingPerBundle: defaultAllowCachingPerBundle,
+  };
+}
+
 /**
  * For large applications with many addons (and many instances of each, resulting in
  * potentially many millions of addon instances during a build), the build can become
@@ -66,6 +104,7 @@ class PerBundleAddonCache {
 
     // Indicate if ember-engines transitive dedupe is enabled.
     this.engineAddonTransitiveDedupeEnabled = !!process.env.EMBER_ENGINES_ADDON_DEDUPE;
+    this._perBundleAddonCacheUtil = resolvePerBundleAddonCacheUtil(this.project);
 
     // For stats purposes, counts on the # addons and proxies created. Addons we
     // can compare against the bundleHostCache addon caches. Proxies, not so much,
@@ -74,6 +113,22 @@ class PerBundleAddonCache {
     this.numProxies = 0;
   }
 
+  /**
+   * The default implementation here is to indicate if the original addon entry point has
+   * the `allowCachingPerBundle` flag set either on itself or on its prototype.
+   *
+   * If a consuming application specifies a relative path to a custom utility via the
+   * `ember-addon.perBundleAddonCacheUtil` configuration, we prefer the custom implementation
+   * provided by the consumer.
+   *
+   * @method allowCachingPerBundle
+   * @param {Object|Function} addonEntryPointModule
+   * @return {Boolean} true if the given constructor function or class supports caching per bundle, false otherwise
+   */
+  allowCachingPerBundle(addonEntryPointModule) {
+    return this._perBundleAddonCacheUtil.allowCachingPerBundle({ addonEntryPointModule });
+  }
+
   /**
    * Creates a cache entry for the bundleHostCache. Because we want to use the same sort of proxy
    * for both bundle hosts and for 'regular' addon instances (though their cache entries have


Test patch:
diff --git a/tests/helpers/fixturify-project.js b/tests/helpers/fixturify-project.js
index fc519b83e..96c43bb1f 100644
--- a/tests/helpers/fixturify-project.js
+++ b/tests/helpers/fixturify-project.js
@@ -17,7 +17,9 @@ class ProjectWithoutInternalAddons extends Project {
 function prepareAddon(addon, options) {
   addon.pkg.keywords.push('ember-addon');
   addon.pkg['ember-addon'] = {};
-  addon.files['index.js'] = `module.exports = {
+  addon.files['index.js'] =
+    options.addonEntryPoint ||
+    `module.exports = {
     name: require("./package").name,
     allowCachingPerBundle: ${Boolean(options.allowCachingPerBundle)},
     ${options.additionalContent || ''}
diff --git a/tests/unit/models/per-bundle-addon-cache/proxy-test.js b/tests/unit/models/per-bundle-addon-cache/proxy-test.js
index 6be9aa2c0..b1373fd8f 100644
--- a/tests/unit/models/per-bundle-addon-cache/proxy-test.js
+++ b/tests/unit/models/per-bundle-addon-cache/proxy-test.js
@@ -1012,6 +1012,80 @@ describe('models/per-bundle-addon-cache', function () {
 
       expect(areAllInstancesEqualWithinHost(project, 'lazy-engine-a')).to.be.true;
     });
+
+    it('uses the custom `per-bundle-addon-cache.js` util if it exists', function () {
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0');
+
+      fixturifyProject.addInRepoAddon('test-addon-b', '1.0.0', (addon) => {
+        addon.pkg['ember-addon'].paths = ['../test-addon-a'];
+      });
+
+      fixturifyProject.pkg['ember-addon'].perBundleAddonCacheUtil = './per-bundle-addon-cache.js';
+
+      fixturifyProject.files['per-bundle-addon-cache.js'] = `
+'use strict';
+
+function allowCachingPerBundle({ addonEntryPointModule }) {
+  if (addonEntryPointModule.name === 'test-addon-a') {
+    return true;
+  }
+
+  return false;
+}
+
+module.exports = {
+  allowCachingPerBundle,
+};
+      `;
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { byName } = countAddons(project);
+
+      expect(byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-a'].proxyCount).to.equal(1);
+    });
+
+    it('throws an error if the provided `perBundleAddonCacheUtil` does not exist', function () {
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0');
+
+      fixturifyProject.addInRepoAddon('test-addon-b', '1.0.0', (addon) => {
+        addon.pkg['ember-addon'].paths = ['../test-addon-a'];
+      });
+
+      fixturifyProject.pkg['ember-addon'].perBundleAddonCacheUtil = './per-bundle-addon-cache.js';
+
+      expect(() => {
+        fixturifyProject.buildProjectModel();
+      }).to.throw(
+        '[ember-cli] the provided `./per-bundle-addon-cache.js` for `ember-addon.perBundleAddonCacheUtil` does not exist'
+      );
+    });
+
+    it('bundle caching works for addons that opt-in via `prototype.allowCachingPerBundle`', function () {
+      fixturifyProject.addInRepoAddon('test-addon-a', '1.0.0', {
+        addonEntryPoint: `
+          function Addon() {}
+          Addon.prototype.allowCachingPerBundle = true;
+          Addon.prototype.pkg = require('./package.json');
+          Addon.prototype.name = 'test-addon-a';
+          module.exports = Addon;
+        `,
+      });
+
+      fixturifyProject.addInRepoAddon('test-addon-b', '1.0.0', (addon) => {
+        addon.pkg['ember-addon'].paths = ['../test-addon-a'];
+      });
+
+      let project = fixturifyProject.buildProjectModel();
+      project.initializeAddons();
+
+      let { byName } = countAddons(project);
+
+      expect(byName['test-addon-a'].realAddonInstanceCount).to.equal(1);
+      expect(byName['test-addon-a'].proxyCount).to.equal(1);
+    });
   });
 
   describe('validation', function () {