FIX: In test mode, initializers were modifying classes over and over

FIX: In test mode, initializers were modifying classes over and over

This adds a new property, pluginId which you can pass to modifyClass which prevent the class from being modified over and over again.

This also includes a fix for polls which was leaking state between tests which this new functionality exposed.

diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 71a6210..35877fa 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -86,6 +86,25 @@ import { addSearchSuggestion } from "discourse/widgets/search-menu-results";
 // If you add any methods to the API ensure you bump up this number
 const PLUGIN_API_VERSION = "0.12.2";
 
+// This helper prevents us from applying the same `modifyClass` over and over in test mode.
+function canModify(klass, type, resolverName, changes) {
+  if (!changes.pluginId) {
+    // eslint-disable-next-line no-console
+    console.warn(
+      "To prevent errors, add a `pluginId` key to your changes when calling `modifyClass`"
+    );
+    return true;
+  }
+
+  let key = "_" + type + "/" + changes.pluginId + "/" + resolverName;
+  if (klass.class[key]) {
+    return false;
+  } else {
+    klass.class[key] = 1;
+    return true;
+  }
+}
+
 class PluginApi {
   constructor(version, container) {
     this.version = version;
@@ -138,10 +157,14 @@ class PluginApi {
   /**
    * Allows you to overwrite or extend methods in a class.
    *
+   * You should add a `pluginId` property to identify your plugin
+   * to help Discourse reload classes properly.
+   *
    * For example:
    *
    * `‍``
    * api.modifyClass('controller:composer', {
+   *   pluginId: 'my-plugin',
    *   actions: {
    *     newActionHere() { }
    *   }
@@ -150,9 +173,15 @@ class PluginApi {
    **/
   modifyClass(resolverName, changes, opts) {
     const klass = this._resolveClass(resolverName, opts);
-    if (klass) {
+    if (!klass) {
+      return;
+    }
+
+    if (canModify(klass, "member", resolverName, changes)) {
+      delete changes.pluginId;
       klass.class.reopen(changes);
     }
+
     return klass;
   }
 
@@ -169,9 +198,15 @@ class PluginApi {
    **/
   modifyClassStatic(resolverName, changes, opts) {
     const klass = this._resolveClass(resolverName, opts);
-    if (klass) {
+    if (!klass) {
+      return;
+    }
+
+    if (canModify(klass, "static", resolverName, changes)) {
+      delete changes.pluginId;
       klass.class.reopenClass(changes);
     }
+
     return klass;
   }
 
diff --git a/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6 b/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6
index a086779..11b2117 100644
--- a/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6
+++ b/plugins/discourse-details/assets/javascripts/initializers/apply-details.js.es6
@@ -15,6 +15,7 @@ function initializeDetails(api) {
   });
 
   api.modifyClass("controller:composer", {
+    pluginId: "discourse-details",
     actions: {
       insertDetails() {
         this.toolbarEvent.applySurround(
diff --git a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6 b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6
index 68b94e7..30c1b5c 100644
--- a/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6
+++ b/plugins/discourse-local-dates/assets/javascripts/initializers/discourse-local-dates.js.es6
@@ -82,6 +82,7 @@ function initializeDiscourseLocalDates(api) {
   });
 
   api.modifyClass("component:d-editor", {
+    pluginId: "discourse-local-dates",
     actions: {
       insertDiscourseLocalDate(toolbarEvent) {
         showModal("discourse-local-dates-create-modal").setProperties({
diff --git a/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6 b/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6
index 156c4dd..89d8c46 100644
--- a/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6
+++ b/plugins/discourse-narrative-bot/assets/javascripts/initializers/new-user-narrative.js.es6
@@ -1,12 +1,15 @@
 import { ajax } from "discourse/lib/ajax";
 import { withPluginApi } from "discourse/lib/plugin-api";
 
+const PLUGIN_ID = "new-user-narrative";
+
 function initialize(api) {
   const messageBus = api.container.lookup("message-bus:main");
   const currentUser = api.getCurrentUser();
   const appEvents = api.container.lookup("service:app-events");
 
   api.modifyClass("component:site-header", {
+    pluginId: PLUGIN_ID,
     didInsertElement() {
       this._super(...arguments);
       this.dispatch("header:search-context-trigger", "header");
@@ -14,6 +17,8 @@ function initialize(api) {
   });
 
   api.modifyClass("controller:topic", {
+    pluginId: PLUGIN_ID,
+
     _togglePostBookmark(post) {
       // if we are talking to discobot then any bookmarks should just
       // be created without reminder options, to streamline the new user
diff --git a/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6
index abeb305..ab5c59c 100644
--- a/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6
+++ b/plugins/poll/assets/javascripts/initializers/add-poll-ui-builder.js.es6
@@ -4,6 +4,7 @@ import { withPluginApi } from "discourse/lib/plugin-api";
 
 function initializePollUIBuilder(api) {
   api.modifyClass("controller:composer", {
+    pluginId: "discourse-poll-ui-builder",
     @discourseComputed(
       "siteSettings.poll_enabled",
       "siteSettings.poll_minimum_trust_level_to_create",
diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
index 6d95057..ab0fb09 100644
--- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
+++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
@@ -4,10 +4,30 @@ import { getRegister } from "discourse-common/lib/get-owner";
 import { observes } from "discourse-common/utils/decorators";
 import { withPluginApi } from "discourse/lib/plugin-api";
 
+const PLUGIN_ID = "discourse-poll";
+let _glued = [];
+let _interval = null;
+
+function rerender() {
+  _glued.forEach((g) => g.queueRerender());
+}
+
+function cleanUpPolls() {
+  if (_interval) {
+    clearInterval(_interval);
+    _interval = null;
+  }
+
+  _glued.forEach((g) => g.cleanUp());
+  _glued = [];
+}
+
 function initializePolls(api) {
   const register = getRegister(api);
+  cleanUpPolls();
 
   api.modifyClass("controller:topic", {
+    pluginId: PLUGIN_ID,
     subscribe() {
       this._super(...arguments);
       this.messageBus.subscribe("/polls/" + this.get("model.id"), (msg) => {
@@ -23,14 +43,8 @@ function initializePolls(api) {
     },
   });
 
-  let _glued = [];
-  let _interval = null;
-
-  function rerender() {
-    _glued.forEach((g) => g.queueRerender());
-  }
-
   api.modifyClass("model:post", {
+    pluginId: PLUGIN_ID,
     _polls: null,
     pollsObject: null,
 
@@ -110,16 +124,6 @@ function initializePolls(api) {
     });
   }
 
-  function cleanUpPolls() {
-    if (_interval) {
-      clearInterval(_interval);
-      _interval = null;
-    }
-
-    _glued.forEach((g) => g.cleanUp());
-    _glued = [];
-  }
-
   api.includePostAttributes("polls", "polls_votes");
   api.decorateCooked(attachPolls, { onlyStream: true, id: "discourse-poll" });
   api.cleanupStream(cleanUpPolls);

GitHub sha: 09764291b1ecf05a363e7c8680c45ddfd87961dd

This commit appears in #14213 which was approved by CvX. It was merged by eviltrout.