🐛 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.
This commit is contained in:
Elena Torro 2026-10-05 16:25:21 +02:00
parent 341e5d1027
commit e46ec5e595
3 changed files with 78 additions and 9 deletions

View File

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

View File

@ -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<ReturnType<typeof createPlugin>>;
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<ReturnType<typeof createPlugin>>;
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);

View File

@ -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.
//