From 1e62fbbee1b719ca7b23debe02a40fbb526ad920 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 08:22:55 +0200 Subject: [PATCH] fix: start each plugin once, so its hooks don't fire twice --- lib/__tests__/plugin-sandbox-loader.test.ts | 10 ++++++++++ lib/plugin-sandbox/loader.ts | 22 ++++++++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/plugin-sandbox-loader.test.ts b/lib/__tests__/plugin-sandbox-loader.test.ts index d660a6776..7875fd435 100644 --- a/lib/__tests__/plugin-sandbox-loader.test.ts +++ b/lib/__tests__/plugin-sandbox-loader.test.ts @@ -243,3 +243,13 @@ describe('failing hooks', () => { expect(markAutoDisabled).toHaveBeenCalledWith('managed-plugin'); }); }); + +describe('loading the same plugin twice', () => { + it('starts one background instance, also when the loads overlap', async () => { + mocks.getCode.mockResolvedValue(CODE); + await Promise.all([loadSandboxedPlugin(plugin()), loadSandboxedPlugin(plugin())]); + await loadSandboxedPlugin(plugin()); + + expect(mocks.createBackgroundInstance).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 26adc17a8..212ee393c 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -18,7 +18,7 @@ import { verifyBundle } from './bundle-integrity'; import { downloadManagedBundle } from './bundle-fetch'; import { createBackgroundInstance, forEachInstance } from './host-bridge'; import { resolvePluginTier } from './tier'; -import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry'; +import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive } from './registry'; import { cancelPluginDialogs } from './host-api'; import { registerShortcuts } from './shortcuts'; import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions'; @@ -148,8 +148,24 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } -export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { - if (typeof window === 'undefined') return; +const loading = new Map>(); + +/** + * Start a plugin. Calling it again for a plugin that is running or still + * starting does nothing: start-up and a force-enable sync used to both load + * a plugin, leaving two background iframes whose hooks all fired twice. + */ +export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise { + if (typeof window === 'undefined') return Promise.resolve(); + if (getActive(plugin.id)) return Promise.resolve(); + const pending = loading.get(plugin.id); + if (pending) return pending; + const run = startSandboxedPlugin(plugin).finally(() => loading.delete(plugin.id)); + loading.set(plugin.id, run); + return run; +} + +async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { let background: ReturnType | null = null; try {