From e46ec5e595a0fd46517b4b3c4636a8fd721045c6 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 5 Oct 2026 16:25:21 +0200 Subject: [PATCH] :bug: Keep background plugins registered when loading another plugin Close only foreground plugins, plus the plugin being reloaded, so background plugins like MCP keep receiving their iframe messages. --- plugins/CHANGELOG.md | 1 + .../src/lib/load-plugin.spec.ts | 61 +++++++++++++++++++ .../plugins-runtime/src/lib/load-plugin.ts | 25 +++++--- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/plugins/CHANGELOG.md b/plugins/CHANGELOG.md index 5c1e07dd74..4035ecc65a 100644 --- a/plugins/CHANGELOG.md +++ b/plugins/CHANGELOG.md @@ -8,6 +8,7 @@ ### 🩹 Fixes +- **plugins-runtime**: Opening a plugin no longer cuts off background plugins (such as the MCP plugin) from their UI. They stayed open but were dropped from the runtime registry, so messages from their iframe were ignored and they could no longer be closed. - **plugins-runtime**: An interaction obtained from `Shape.interactions` now keeps addressing that interaction instead of the position it held when the array was read. Removing every interaction of a shape from a single read removes all of them rather than leaving some behind, and writing through a held interaction after an earlier one is removed no longer lands on a different interaction. - **plugins-runtime**: `Shape.removeInteraction()` now rejects an interaction belonging to a different shape with a validation error, instead of removing whichever interaction sat at the same position on the target shape. - **plugins-runtime**: Writing `trigger`, `delay` or `action` on an interaction the shape no longer has now raises a validation error. The write used to be sent to the workspace with no position to apply it at, where it failed out of the plugin's reach: nothing was written and nothing was reported. diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts index 810ebd54b3..6d7b895b79 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.spec.ts @@ -72,6 +72,7 @@ describe('plugin-loader', () => { }); afterEach(() => { + vi.mocked(createPlugin).mock.calls.forEach(([, , onClose]) => onClose()); vi.clearAllMocks(); }); @@ -200,6 +201,66 @@ describe('plugin-loader', () => { expect(mockPluginApi1.plugin.sendMessage).not.toHaveBeenCalled(); }); + it('should keep routing messages to a background plugin after loading another', async () => { + const backgroundIframeWindow = { nodeType: 1 } as unknown as Window; + const backgroundManifest = { + ...manifest, + pluginId: 'background-plugin', + allowBackground: true, + }; + const backgroundPluginApi = { + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + iframeWindow: backgroundIframeWindow, + manifest: backgroundManifest, + } as unknown as Awaited>; + + vi.mocked(createPlugin).mockResolvedValue(backgroundPluginApi); + await loadPlugin(backgroundManifest); + + vi.mocked(createPlugin).mockResolvedValue(mockPluginApi); + await loadPlugin(manifest); + + const event = new MessageEvent('message', { data: 'task-response' }); + Object.defineProperty(event, 'source', { value: backgroundIframeWindow }); + window.dispatchEvent(event); + + expect(backgroundPluginApi.plugin.close).not.toHaveBeenCalled(); + expect(backgroundPluginApi.plugin.sendMessage).toHaveBeenCalledWith( + 'task-response', + ); + expect(getPlugins()).toEqual([backgroundPluginApi, mockPluginApi]); + }); + + it('should replace a background plugin when loading it again', async () => { + const backgroundManifest = { + ...manifest, + pluginId: 'background-plugin', + allowBackground: true, + }; + const makeBackgroundPluginApi = () => + ({ + plugin: { + close: vi.fn(), + sendMessage: vi.fn(), + }, + manifest: backgroundManifest, + }) as unknown as Awaited>; + const firstPluginApi = makeBackgroundPluginApi(); + const secondPluginApi = makeBackgroundPluginApi(); + + vi.mocked(createPlugin).mockResolvedValue(firstPluginApi); + await loadPlugin(backgroundManifest); + + vi.mocked(createPlugin).mockResolvedValue(secondPluginApi); + await loadPlugin(backgroundManifest); + + expect(firstPluginApi.plugin.close).toHaveBeenCalled(); + expect(getPlugins()).toEqual([secondPluginApi]); + }); + it('should load plugin using ɵloadPlugin', async () => { await ɵloadPlugin(manifest); diff --git a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts index dfd44f4971..a18ab6497f 100644 --- a/plugins/libs/plugins-runtime/src/lib/load-plugin.ts +++ b/plugins/libs/plugins-runtime/src/lib/load-plugin.ts @@ -16,15 +16,22 @@ export function setContextBuilder(builder: ContextBuilder) { export const getPlugins = () => plugins; -const closeAllPlugins = () => { - plugins.forEach((pluginApi) => { - /* eslint-disable @typescript-eslint/no-explicit-any */ - if (!(pluginApi.manifest as any)?.allowBackground) { - pluginApi.plugin.close(); - } - }); +const isBackgroundPlugin = (pluginApi: (typeof plugins)[number]) => + /* eslint-disable @typescript-eslint/no-explicit-any */ + !!(pluginApi.manifest as any)?.allowBackground; - plugins = []; +// Background plugins stay registered so their iframe messages keep routing, +// unless the same plugin is being loaded again. +const closeAllPlugins = (pluginId?: Manifest['pluginId']) => { + const closing = plugins.filter( + (pluginApi) => + !isBackgroundPlugin(pluginApi) || + pluginApi.manifest.pluginId === pluginId, + ); + + closing.forEach((pluginApi) => pluginApi.plugin.close()); + + plugins = plugins.filter((pluginApi) => !closing.includes(pluginApi)); }; window.addEventListener('message', (event) => { @@ -51,7 +58,7 @@ export const loadPlugin = async function ( return; } - closeAllPlugins(); + closeAllPlugins(manifest.pluginId); // The host context is not deeply frozen at this load stage. //