MM-67538 Add ability for plugins to load asynchronously (#35238)
Some checks are pending
API / build (push) Waiting to run
Server CI / Compute Go Version (push) Waiting to run
Server CI / Check mocks (push) Blocked by required conditions
Server CI / Check go mod tidy (push) Blocked by required conditions
Server CI / check-style (push) Blocked by required conditions
Server CI / Check serialization methods for hot structs (push) Blocked by required conditions
Server CI / Vet API (push) Blocked by required conditions
Server CI / Check migration files (push) Blocked by required conditions
Server CI / Generate email templates (push) Blocked by required conditions
Server CI / Check store layers (push) Blocked by required conditions
Server CI / Check mmctl docs (push) Blocked by required conditions
Server CI / Postgres with binary parameters (push) Blocked by required conditions
Server CI / Postgres (push) Blocked by required conditions
Server CI / Postgres (FIPS) (push) Blocked by required conditions
Server CI / Generate Test Coverage (push) Blocked by required conditions
Server CI / Run mmctl tests (push) Blocked by required conditions
Server CI / Run mmctl tests (FIPS) (push) Blocked by required conditions
Server CI / Build mattermost server app (push) Blocked by required conditions
Web App CI / check-lint (push) Waiting to run
Web App CI / check-i18n (push) Blocked by required conditions
Web App CI / check-types (push) Blocked by required conditions
Web App CI / test (platform) (push) Blocked by required conditions
Web App CI / test (mattermost-redux) (push) Blocked by required conditions
Web App CI / test (channels shard 1/4) (push) Blocked by required conditions
Web App CI / test (channels shard 2/4) (push) Blocked by required conditions
Web App CI / test (channels shard 3/4) (push) Blocked by required conditions
Web App CI / test (channels shard 4/4) (push) Blocked by required conditions
Web App CI / upload-coverage (push) Blocked by required conditions
Web App CI / build (push) Blocked by required conditions

Automatic Merge
This commit is contained in:
Harrison Healey 2026-02-11 01:23:28 -05:00 committed by GitHub
parent a711b22717
commit 1a4de869b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 40 additions and 81 deletions

View file

@ -957,14 +957,12 @@ describe('handleCloudSubscriptionChanged', () => {
});
describe('handlePluginEnabled/handlePluginDisabled', () => {
const origLog = console.log;
const origError = console.error;
const origCreateElement = document.createElement;
const origGetElementsByTagName = document.getElementsByTagName;
const origWindowPlugins = window.plugins;
afterEach(() => {
console.log = origLog;
console.error = origError;
document.createElement = origCreateElement;
document.getElementsByTagName = origGetElementsByTagName;
@ -990,8 +988,7 @@ describe('handlePluginEnabled/handlePluginDisabled', () => {
};
beforeEach(async () => {
console.log = jest.fn();
console.error = jest.fn();
console.error = jest.fn((...args) => origError(...args));
document.createElement = jest.fn();
document.getElementsByTagName = jest.fn();
@ -1000,60 +997,36 @@ describe('handlePluginEnabled/handlePluginDisabled', () => {
}]);
});
test('when a plugin is enabled', () => {
test('when a plugin is enabled', async () => {
const manifest = {
...baseManifest,
id: 'com.mattermost.demo-plugin',
};
const initialize = jest.fn();
window.plugins = {
[manifest.id]: {
initialize,
},
};
const mockScript = {};
document.createElement.mockReturnValue(mockScript);
expect(mockScript.onload).toBeUndefined();
handlePluginEnabled({data: {manifest}});
expect(document.createElement).toHaveBeenCalledWith('script');
expect(document.getElementsByTagName).toHaveBeenCalledTimes(1);
expect(document.getElementsByTagName()[0].appendChild).toHaveBeenCalledTimes(1);
expect(mockScript.onload).toBeInstanceOf(Function);
// Pretend to be a browser, invoke onload
mockScript.onload();
expect(initialize).toHaveBeenCalledWith(expect.anything(), store);
const registery = initialize.mock.calls[0][0];
const mockComponent = 'mockRootComponent';
registery.registerRootComponent(mockComponent);
expect(store.dispatch).toHaveBeenCalledTimes(1);
let dispatchArg = store.dispatch.mock.calls[0][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_WEBAPP_PLUGIN);
expect(dispatchArg.data).toBe(manifest);
dispatchArg = store.dispatch.mock.calls[1][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_PLUGIN_COMPONENT);
expect(dispatchArg.name).toBe('Root');
expect(dispatchArg.data.component).toBe(mockComponent);
expect(dispatchArg.data.pluginId).toBe(manifest.id);
// Assert handlePluginEnabled is idempotent
handlePluginEnabled({data: {manifest}});
expect(store.dispatch).toHaveBeenCalledTimes(2);
// Assert handlePluginEnabled is idempotent
mockScript.onload = undefined;
handlePluginEnabled({data: {manifest}});
expect(mockScript.onload).toBeUndefined();
dispatchArg = store.dispatch.mock.calls[2][0];
dispatchArg = store.dispatch.mock.calls[1][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_WEBAPP_PLUGIN);
expect(dispatchArg.data).toBe(manifest);
expect(store.dispatch).toHaveBeenCalledTimes(3);
expect(console.error).toHaveBeenCalledTimes(0);
});
@ -1062,12 +1035,6 @@ describe('handlePluginEnabled/handlePluginDisabled', () => {
...baseManifest,
id: 'com.mattermost.demo-2-plugin',
};
const initialize = jest.fn();
window.plugins = {
[manifest.id]: {
initialize,
},
};
const manifestv2 = {
...manifest,
@ -1080,69 +1047,39 @@ describe('handlePluginEnabled/handlePluginDisabled', () => {
const mockScript = {};
document.createElement.mockReturnValue(mockScript);
expect(mockScript.onload).toBeUndefined();
handlePluginEnabled({data: {manifest}});
expect(document.createElement).toHaveBeenCalledWith('script');
expect(document.getElementsByTagName).toHaveBeenCalledTimes(1);
expect(document.getElementsByTagName()[0].appendChild).toHaveBeenCalledTimes(1);
expect(mockScript.onload).toBeInstanceOf(Function);
// Pretend to be a browser, invoke onload
mockScript.onload();
expect(initialize).toHaveBeenCalledWith(expect.anything(), store);
const registry = initialize.mock.calls[0][0];
const mockComponent = 'mockRootComponent';
registry.registerRootComponent(mockComponent);
let dispatchArg = store.dispatch.mock.calls[0][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_WEBAPP_PLUGIN);
expect(dispatchArg.data).toBe(manifest);
dispatchArg = store.dispatch.mock.calls[1][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_PLUGIN_COMPONENT);
expect(dispatchArg.name).toBe('Root');
expect(dispatchArg.data.component).toBe(mockComponent);
expect(dispatchArg.data.pluginId).toBe(manifest.id);
// Upgrade plugin
mockScript.onload = undefined;
handlePluginEnabled({data: {manifest: manifestv2}});
// Assert upgrade is idempotent
handlePluginEnabled({data: {manifest: manifestv2}});
expect(mockScript.onload).toBeInstanceOf(Function);
expect(document.createElement).toHaveBeenCalledTimes(2);
mockScript.onload();
expect(initialize).toHaveBeenCalledWith(expect.anything(), store);
expect(initialize).toHaveBeenCalledTimes(2);
const registry2 = initialize.mock.calls[0][0];
const mockComponent2 = 'mockRootComponent2';
registry2.registerRootComponent(mockComponent2);
dispatchArg = store.dispatch.mock.calls[1][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_WEBAPP_PLUGIN);
expect(dispatchArg.data).toBe(manifestv2);
dispatchArg = store.dispatch.mock.calls[2][0];
expect(store.dispatch).toHaveBeenCalledTimes(4);
const dispatchRemovedArg = store.dispatch.mock.calls[2][0];
expect(typeof dispatchRemovedArg).toBe('function');
dispatchRemovedArg(store.dispatch);
dispatchArg = store.dispatch.mock.calls[3][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_WEBAPP_PLUGIN);
expect(dispatchArg.data).toBe(manifestv2);
expect(store.dispatch).toHaveBeenCalledTimes(6);
const dispatchRemovedArg = store.dispatch.mock.calls[3][0];
expect(typeof dispatchRemovedArg).toBe('function');
dispatchRemovedArg(store.dispatch);
dispatchArg = store.dispatch.mock.calls[4][0];
expect(dispatchArg.type).toBe(ActionTypes.RECEIVED_WEBAPP_PLUGIN);
expect(dispatchArg.data).toBe(manifestv2);
const dispatchReceivedArg2 = store.dispatch.mock.calls[5][0];
expect(dispatchReceivedArg2.type).toBe(ActionTypes.RECEIVED_PLUGIN_COMPONENT);
expect(dispatchReceivedArg2.name).toBe('Root');
expect(dispatchReceivedArg2.data.component).toBe(mockComponent2);
expect(dispatchReceivedArg2.data.pluginId).toBe(manifest.id);
expect(store.dispatch).toHaveBeenCalledTimes(8);
const dispatchReceivedArg4 = store.dispatch.mock.calls[7][0];
const dispatchReceivedArg4 = store.dispatch.mock.calls[5][0];
expect(dispatchReceivedArg4.type).toBe(ActionTypes.REMOVED_WEBAPP_PLUGIN);
expect(dispatchReceivedArg4.data).toBe(manifestv2);
@ -1170,7 +1107,6 @@ describe('handlePluginEnabled/handlePluginDisabled', () => {
};
beforeEach(async () => {
console.log = jest.fn();
console.error = jest.fn();
document.createElement = jest.fn();

View file

@ -67,6 +67,8 @@ function registerPlugin(id: string, plugin: Plugin): void {
}
window.plugins[id] = plugin;
onPluginRegistered(id);
}
window.registerPlugin = registerPlugin;
@ -168,6 +170,8 @@ export function loadPlugin(manifest: PluginManifest): Promise<void> {
bundlePath = bundlePath.replace('/static/', '/static/plugins/');
}
addPluginRegisteredHandler(manifest.id, onLoad);
console.log('Loading ' + describePlugin(manifest)); //eslint-disable-line no-console
const script = document.createElement('script');
@ -175,7 +179,6 @@ export function loadPlugin(manifest: PluginManifest): Promise<void> {
script.type = 'text/javascript';
script.src = getSiteURL() + bundlePath;
script.defer = true;
script.onload = onLoad;
script.onerror = onError;
document.getElementsByTagName('head')[0].appendChild(script);
@ -227,6 +230,26 @@ export function removePlugin(manifest: PluginManifest): void {
console.log('Removed ' + describePlugin(manifest)); //eslint-disable-line no-console
}
type PluginRegisteredListener = () => void;
const pluginRegisteredHandlers = new Map<string, PluginRegisteredListener>();
function addPluginRegisteredHandler(pluginId: string, listener: PluginRegisteredListener) {
pluginRegisteredHandlers.set(pluginId, listener);
}
function onPluginRegistered(pluginId: string) {
const listener = pluginRegisteredHandlers.get(pluginId);
if (listener) {
pluginRegisteredHandlers.delete(pluginId);
listener();
} else {
// eslint-disable-next-line no-console
console.error('A plugin was registered, but no listener has been registered for it. It won\'t be loaded correctly.');
}
}
// loadPluginsIfNecessary synchronizes the current state of loaded plugins with that of the server,
// loading any newly added plugins and unloading any removed ones.
export async function loadPluginsIfNecessary(): Promise<void> {