mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
DEV: Fixes for modifyClass
(#28227)
This resolves issues when a mix of callback-based modifications and Ember-reopen-based modifications are used on the same target. In summary: - Fixes `pluginId` exception logic for callback-based modifications - Moves `pluginId` storage to a WeakMap so it doesn't pollute the target's descriptors - When applying a legacy modifyClass, we will temporarily rollback any modern callback-based modifications. This means all of Ember's reopen calls apply to un-prepended classes, and then we add our modern prepends on top. - Calls `.proto()` on CoreObject descendants before prepending, to ensure that pending Ember mixins have been applied
This commit is contained in:
parent
c8c859762b
commit
2c8d703b48
@ -1,9 +1,19 @@
|
|||||||
import { DEBUG } from "@glimmer/env";
|
import CoreObject from "@ember/object/core";
|
||||||
import { isTesting } from "discourse-common/config/environment";
|
|
||||||
|
|
||||||
const RESERVED_CLASS_PROPS = ["prototype", "name", "length"];
|
const RESERVED_CLASS_PROPS = ["prototype", "name", "length"];
|
||||||
const RESERVED_PROTOTYPE_PROPS = ["constructor"];
|
const RESERVED_PROTOTYPE_PROPS = ["constructor"];
|
||||||
|
|
||||||
|
function hasAncestor(klass, ancestor) {
|
||||||
|
let current = klass;
|
||||||
|
while (current) {
|
||||||
|
if (current === ancestor) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = Object.getPrototypeOf(current);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function provides a way to add/modify instance and static properties on an existing JS class, including
|
* This function provides a way to add/modify instance and static properties on an existing JS class, including
|
||||||
* the ability to use `super` to call the original implementation.
|
* the ability to use `super` to call the original implementation.
|
||||||
@ -13,9 +23,14 @@ const RESERVED_PROTOTYPE_PROPS = ["constructor"];
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export default function classPrepend(klass, callback) {
|
export default function classPrepend(klass, callback) {
|
||||||
|
if (hasAncestor(klass, CoreObject)) {
|
||||||
|
// Ensure any prior reopen() calls have been applied
|
||||||
|
klass.proto();
|
||||||
|
}
|
||||||
|
|
||||||
const originalKlassDescs = Object.getOwnPropertyDescriptors(klass);
|
const originalKlassDescs = Object.getOwnPropertyDescriptors(klass);
|
||||||
const originalProtoDescs = Object.getOwnPropertyDescriptors(klass.prototype);
|
const originalProtoDescs = Object.getOwnPropertyDescriptors(klass.prototype);
|
||||||
logDescriptorInfoForRollback(klass, originalKlassDescs, originalProtoDescs);
|
logInfo(klass, originalKlassDescs, originalProtoDescs, callback);
|
||||||
|
|
||||||
for (const key of RESERVED_CLASS_PROPS) {
|
for (const key of RESERVED_CLASS_PROPS) {
|
||||||
delete originalKlassDescs[key];
|
delete originalKlassDescs[key];
|
||||||
@ -64,36 +79,73 @@ export default function classPrepend(klass, callback) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let originalDescriptorInfo;
|
const prependInfo = new Map();
|
||||||
|
|
||||||
if (DEBUG && isTesting()) {
|
/**
|
||||||
originalDescriptorInfo = new Map();
|
* Log the previous state of a class so that it can be rolled back later.
|
||||||
|
*/
|
||||||
|
function logInfo(klass, klassDescs, protoDescs, modifyCallback) {
|
||||||
|
const info = prependInfo.get(klass) || {
|
||||||
|
klassDescs,
|
||||||
|
protoDescs,
|
||||||
|
modifyCallbacks: [],
|
||||||
|
};
|
||||||
|
info.modifyCallbacks.push(modifyCallback);
|
||||||
|
prependInfo.set(klass, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logDescriptorInfoForRollback(klass, klassDescs, protoDescs) {
|
/**
|
||||||
if (DEBUG && isTesting() && !originalDescriptorInfo.has(klass)) {
|
* Rollback a specific class to its state before any prepends were applied.
|
||||||
originalDescriptorInfo.set(klass, {
|
*/
|
||||||
klassDescs,
|
function rollbackPrepends(klass) {
|
||||||
protoDescs,
|
const { klassDescs, protoDescs } = prependInfo.get(klass);
|
||||||
});
|
|
||||||
|
for (const [key, descriptor] of Object.entries(klassDescs)) {
|
||||||
|
Object.defineProperty(klass, key, descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.getOwnPropertyNames(klass)) {
|
||||||
|
if (!RESERVED_CLASS_PROPS.includes(key) && !klassDescs[key]) {
|
||||||
|
delete klass[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, descriptor] of Object.entries(protoDescs)) {
|
||||||
|
Object.defineProperty(klass.prototype, key, descriptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.getOwnPropertyNames(klass.prototype)) {
|
||||||
|
if (!RESERVED_PROTOTYPE_PROPS.includes(key) && !protoDescs[key]) {
|
||||||
|
delete klass.prototype[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prependInfo.delete(klass);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback all prepends on a class, run a callback, then re-apply the prepends.
|
||||||
|
*/
|
||||||
|
export function withPrependsRolledBack(klass, callback) {
|
||||||
|
const info = prependInfo.get(klass);
|
||||||
|
if (!info) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rollbackPrepends(klass);
|
||||||
|
try {
|
||||||
|
callback();
|
||||||
|
} finally {
|
||||||
|
info.modifyCallbacks.forEach((cb) => classPrepend(klass, cb));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rollback all descriptors to their original values. This should only be used in tests
|
* Rollback all descriptors to their original values. This should only be used in tests
|
||||||
*/
|
*/
|
||||||
export function rollbackAllModifications() {
|
export function rollbackAllPrepends() {
|
||||||
if (DEBUG && isTesting()) {
|
for (const klass of prependInfo.keys()) {
|
||||||
for (const [klass, { klassDescs, protoDescs }] of originalDescriptorInfo) {
|
rollbackPrepends(klass);
|
||||||
for (const [key, descriptor] of Object.entries(klassDescs)) {
|
|
||||||
Object.defineProperty(klass, key, descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, descriptor] of Object.entries(protoDescs)) {
|
|
||||||
Object.defineProperty(klass.prototype, key, descriptor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
originalDescriptorInfo.clear();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -61,7 +61,9 @@ import {
|
|||||||
PLUGIN_NAV_MODE_TOP,
|
PLUGIN_NAV_MODE_TOP,
|
||||||
registerAdminPluginConfigNav,
|
registerAdminPluginConfigNav,
|
||||||
} from "discourse/lib/admin-plugin-config-nav";
|
} from "discourse/lib/admin-plugin-config-nav";
|
||||||
import classPrepend from "discourse/lib/class-prepend";
|
import classPrepend, {
|
||||||
|
withPrependsRolledBack,
|
||||||
|
} from "discourse/lib/class-prepend";
|
||||||
import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options";
|
import { addPopupMenuOption } from "discourse/lib/composer/custom-popup-menu-options";
|
||||||
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
|
import { registerDesktopNotificationHandler } from "discourse/lib/desktop-notifications";
|
||||||
import { downloadCalendar } from "discourse/lib/download-calendar";
|
import { downloadCalendar } from "discourse/lib/download-calendar";
|
||||||
@ -175,9 +177,15 @@ const DEPRECATED_HEADER_WIDGETS = [
|
|||||||
"user-dropdown",
|
"user-dropdown",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const appliedModificationIds = new WeakMap();
|
||||||
|
|
||||||
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
|
||||||
function canModify(klass, type, resolverName, changes) {
|
function canModify(klass, type, resolverName, changes) {
|
||||||
if (typeof changes !== "function" && !changes.pluginId) {
|
if (typeof changes === "function") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changes.pluginId) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn(
|
console.warn(
|
||||||
consolePrefix(),
|
consolePrefix(),
|
||||||
@ -187,10 +195,13 @@ function canModify(klass, type, resolverName, changes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let key = "_" + type + "/" + changes.pluginId + "/" + resolverName;
|
let key = "_" + type + "/" + changes.pluginId + "/" + resolverName;
|
||||||
if (klass.class[key]) {
|
|
||||||
|
if (appliedModificationIds.get(klass.class)?.includes(key)) {
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
klass.class[key] = 1;
|
const modificationIds = appliedModificationIds.get(klass.class) || [];
|
||||||
|
modificationIds.push(key);
|
||||||
|
appliedModificationIds.set(klass.class, modificationIds);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -299,7 +310,9 @@ class PluginApi {
|
|||||||
if (typeof changes === "function") {
|
if (typeof changes === "function") {
|
||||||
classPrepend(klass.class, changes);
|
classPrepend(klass.class, changes);
|
||||||
} else if (klass.class.reopen) {
|
} else if (klass.class.reopen) {
|
||||||
klass.class.reopen(changes);
|
withPrependsRolledBack(klass.class, () => {
|
||||||
|
klass.class.reopen(changes);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
Object.defineProperties(
|
Object.defineProperties(
|
||||||
klass.class.prototype || klass.class,
|
klass.class.prototype || klass.class,
|
||||||
@ -330,7 +343,9 @@ class PluginApi {
|
|||||||
|
|
||||||
if (canModify(klass, "static", resolverName, changes)) {
|
if (canModify(klass, "static", resolverName, changes)) {
|
||||||
delete changes.pluginId;
|
delete changes.pluginId;
|
||||||
klass.class.reopenClass(changes);
|
withPrependsRolledBack(klass.class, () => {
|
||||||
|
klass.class.reopenClass(changes);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return klass;
|
return klass;
|
||||||
|
@ -32,7 +32,7 @@ import { clearHTMLCache } from "discourse/helpers/custom-html";
|
|||||||
import { resetUsernameDecorators } from "discourse/helpers/decorate-username-selector";
|
import { resetUsernameDecorators } from "discourse/helpers/decorate-username-selector";
|
||||||
import { resetBeforeAuthCompleteCallbacks } from "discourse/instance-initializers/auth-complete";
|
import { resetBeforeAuthCompleteCallbacks } from "discourse/instance-initializers/auth-complete";
|
||||||
import { resetAdminPluginConfigNav } from "discourse/lib/admin-plugin-config-nav";
|
import { resetAdminPluginConfigNav } from "discourse/lib/admin-plugin-config-nav";
|
||||||
import { rollbackAllModifications } from "discourse/lib/class-prepend";
|
import { rollbackAllPrepends } from "discourse/lib/class-prepend";
|
||||||
import { clearPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options";
|
import { clearPopupMenuOptions } from "discourse/lib/composer/custom-popup-menu-options";
|
||||||
import { clearDesktopNotificationHandlers } from "discourse/lib/desktop-notifications";
|
import { clearDesktopNotificationHandlers } from "discourse/lib/desktop-notifications";
|
||||||
import { cleanUpHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
|
import { cleanUpHashtagTypeClasses } from "discourse/lib/hashtag-type-registry";
|
||||||
@ -250,7 +250,7 @@ export function testCleanup(container, app) {
|
|||||||
clearAdditionalAdminSidebarSectionLinks();
|
clearAdditionalAdminSidebarSectionLinks();
|
||||||
resetAdminPluginConfigNav();
|
resetAdminPluginConfigNav();
|
||||||
resetTransformers();
|
resetTransformers();
|
||||||
rollbackAllModifications();
|
rollbackAllPrepends();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanupCssGeneratorTags() {
|
function cleanupCssGeneratorTags() {
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import { tracked } from "@glimmer/tracking";
|
import { tracked } from "@glimmer/tracking";
|
||||||
import { action } from "@ember/object";
|
import { action } from "@ember/object";
|
||||||
import { module, test } from "qunit";
|
import { module, test } from "qunit";
|
||||||
import classPrepend, {
|
import classPrepend, { rollbackAllPrepends } from "discourse/lib/class-prepend";
|
||||||
rollbackAllModifications,
|
|
||||||
} from "discourse/lib/class-prepend";
|
|
||||||
|
|
||||||
module("Unit | class-prepend", function () {
|
module("Unit | class-prepend", function () {
|
||||||
test("can override function, with super support", function (assert) {
|
test("can override function, with super support", function (assert) {
|
||||||
@ -273,7 +271,7 @@ module("Unit | class-prepend", function () {
|
|||||||
|
|
||||||
assert.strictEqual(new Topic().someFunction(), 2, "change is applied");
|
assert.strictEqual(new Topic().someFunction(), 2, "change is applied");
|
||||||
|
|
||||||
rollbackAllModifications();
|
rollbackAllPrepends();
|
||||||
|
|
||||||
assert.strictEqual(new Topic().someFunction(), 1, "change is rolled back");
|
assert.strictEqual(new Topic().someFunction(), 1, "change is rolled back");
|
||||||
});
|
});
|
||||||
|
@ -2,6 +2,7 @@ import EmberObject from "@ember/object";
|
|||||||
import { getOwner } from "@ember/owner";
|
import { getOwner } from "@ember/owner";
|
||||||
import { setupTest } from "ember-qunit";
|
import { setupTest } from "ember-qunit";
|
||||||
import { module, test } from "qunit";
|
import { module, test } from "qunit";
|
||||||
|
import { rollbackAllPrepends } from "discourse/lib/class-prepend";
|
||||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
import discourseComputed from "discourse-common/utils/decorators";
|
import discourseComputed from "discourse-common/utils/decorators";
|
||||||
|
|
||||||
@ -153,10 +154,23 @@ module("Unit | Utility | plugin-api", function (hooks) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
api.modifyClass(
|
||||||
|
"test-thingy:main",
|
||||||
|
(Superclass) =>
|
||||||
|
class extends Superclass {
|
||||||
|
someFunction() {
|
||||||
|
return `${super.someFunction()} twice`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const thingyKlass =
|
const thingyKlass =
|
||||||
getOwner(this).resolveRegistration("test-thingy:main");
|
getOwner(this).resolveRegistration("test-thingy:main");
|
||||||
const thingy = new thingyKlass();
|
const thingy = new thingyKlass();
|
||||||
assert.strictEqual(thingy.someFunction(), "original function modified");
|
assert.strictEqual(
|
||||||
|
thingy.someFunction(),
|
||||||
|
"original function modified twice"
|
||||||
|
);
|
||||||
assert.strictEqual(thingy.someGetter, "original getter modified");
|
assert.strictEqual(thingy.someGetter, "original getter modified");
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
TestThingy.someStaticMethod(),
|
TestThingy.someStaticMethod(),
|
||||||
@ -164,4 +178,61 @@ module("Unit | Utility | plugin-api", function (hooks) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("modifyClass works with a combination of callback and legacy syntax", function (assert) {
|
||||||
|
class TestThingy extends EmberObject {
|
||||||
|
someMethod() {
|
||||||
|
return "original";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getOwner(this).register("test-thingy:main", TestThingy);
|
||||||
|
|
||||||
|
const fakeInit = () => {
|
||||||
|
withPluginApi("1.1.0", (api) => {
|
||||||
|
api.modifyClass("test-thingy:main", {
|
||||||
|
someMethod() {
|
||||||
|
return `${this._super()} reopened`;
|
||||||
|
},
|
||||||
|
pluginId: "one",
|
||||||
|
});
|
||||||
|
|
||||||
|
api.modifyClass(
|
||||||
|
"test-thingy:main",
|
||||||
|
(Superclass) =>
|
||||||
|
class extends Superclass {
|
||||||
|
someMethod() {
|
||||||
|
return `${super.someMethod()}, prepended`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
api.modifyClass("test-thingy:main", {
|
||||||
|
someMethod() {
|
||||||
|
return `${this._super()}, reopened2`;
|
||||||
|
},
|
||||||
|
pluginId: "two",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
fakeInit();
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
new TestThingy().someMethod(),
|
||||||
|
"original reopened, reopened2, prepended",
|
||||||
|
"it works after first application"
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
rollbackAllPrepends();
|
||||||
|
fakeInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
new TestThingy().someMethod(),
|
||||||
|
"original reopened, reopened2, prepended",
|
||||||
|
"it works when rolled back and re-applied multiple times"
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user