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:
David Taylor 2024-08-07 12:28:51 +01:00 committed by GitHub
parent c8c859762b
commit 2c8d703b48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 174 additions and 38 deletions

View File

@ -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();
} }
} }

View File

@ -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;

View File

@ -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() {

View File

@ -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");
}); });

View File

@ -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"
);
});
}); });