perf: start plugin slot frames without waiting for the plugin's own frame

This commit is contained in:
Linus Rath
2026-10-25 02:24:08 +01:00
parent e38e326efb
commit 5edbbf0816
8 changed files with 230 additions and 22 deletions
@@ -20,6 +20,12 @@ function post(source: Window | null, origin: string, data: unknown) {
}
const instances: Array<{ destroy(): void }> = [];
/** The frame answers init: its hooks and slots are in place. */
function booted<T extends { iframe: HTMLIFrameElement }>(inst: T): T {
post(inst.iframe.contentWindow, 'null', { type: 'init-done', hooks: [], slots: [], shortcuts: [] });
return inst;
}
afterEach(() => {
for (const i of instances.splice(0)) i.destroy();
vi.clearAllMocks();
@@ -131,7 +137,7 @@ describe('slot sizing', () => {
describe('shouldShow context', () => {
it('leaves host callbacks out instead of failing to post them', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }));
instances.push(inst);
const sent = vi.spyOn(inst.iframe.contentWindow!, 'postMessage');
@@ -144,7 +150,7 @@ describe('shouldShow context', () => {
describe('host bookkeeping', () => {
it('fails a hook call whose arguments cannot be sent, without keeping it pending', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
const inst = booted(createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' }));
instances.push(inst);
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation(() => { throw new DOMException('no clone', 'DataCloneError'); });
@@ -337,3 +343,40 @@ describe('plugin translations in frames', () => {
expect(Object.keys(init.payload.manifest.locales).sort()).toEqual(['en', 'ja']);
});
});
describe('calls before the background frame has booted', () => {
// A plugin offering the slots remembered from its last start is in the
// registry while its frame boots; a message posted before init is lost.
it('holds shouldShow and hook calls until init-done', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
instances.push(inst);
const sent: Array<{ type: string; id?: string }> = [];
vi.spyOn(inst.iframe.contentWindow!, 'postMessage').mockImplementation((m: unknown) => { sent.push(m as { type: string }); });
const show = inst.evaluateShouldShow('email-banner', {});
const hook = inst.invokeHook('onAction', [{ actionId: 'x' }]);
await new Promise((r) => setTimeout(r, 0));
expect(sent.filter((m) => m.type === 'slot-should-show' || m.type === 'hook-invoke')).toEqual([]);
booted(inst);
await new Promise((r) => setTimeout(r, 0));
const ask = sent.find((m) => m.type === 'slot-should-show')!;
const call = sent.find((m) => m.type === 'hook-invoke')!;
post(inst.iframe.contentWindow, 'null', { type: 'slot-should-show-result', id: ask.id, show: true });
post(inst.iframe.contentWindow, 'null', { type: 'hook-result', id: call.id, ok: true, result: 'done' });
await expect(show).resolves.toBe(true);
await expect(hook).resolves.toBe('done');
});
it('gives up on them when the frame is destroyed first', async () => {
const inst = createBackgroundInstance({ plugin: plugin(), code: '', locale: 'en', tier: 'untrusted' });
const show = inst.evaluateShouldShow('email-banner', {});
const hook = inst.invokeHook('onAction', []);
inst.destroy();
await expect(show).resolves.toBe(false);
await expect(hook).rejects.toThrow(/destroyed/);
await expect(inst.initPromise).rejects.toThrow(/destroyed/);
expect(inst.closed).toBe(true);
});
});
+92 -4
View File
@@ -41,7 +41,7 @@ vi.mock('../plugin-sandbox/shortcuts', () => ({
}));
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor, setSandboxLocale } from '../plugin-sandbox/loader';
import { get as getActive } from '../plugin-sandbox/registry';
import { get as getActive, offersForSlot } from '../plugin-sandbox/registry';
const CODE = 'export default { activate() {} }';
const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex');
@@ -67,11 +67,13 @@ function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
}
function fakeBackground() {
return {
initPromise: Promise.resolve({ hooks: [], slots: [], shortcuts: [] }),
const bg = {
initPromise: Promise.resolve({ hooks: [], slots: [], shortcuts: [] }) as Promise<unknown>,
invokeHook: vi.fn(),
destroy: vi.fn(),
closed: false,
destroy: vi.fn(() => { bg.closed = true; }),
};
return bg;
}
let statuses: Array<{ id: string; status: string; error?: string }>;
@@ -260,3 +262,89 @@ describe('loading the same plugin twice', () => {
expect(mocks.createBackgroundInstance).toHaveBeenCalledTimes(1);
});
});
describe('slots remembered from the last start', () => {
const OFFERS = [{ name: 'sidebar-section', hasShouldShow: false, order: 100 }];
const perms = { permissions: ['ui:sidebar-widget'], grantedPermissions: ['ui:sidebar-widget'] } as Partial<InstalledPlugin>;
function startingBackground() {
let finish!: (info: unknown) => void;
let fail!: (err: Error) => void;
const bg = fakeBackground();
bg.initPromise = new Promise((res, rej) => { finish = res; fail = rej; });
mocks.createBackgroundInstance.mockImplementation(() => bg);
return { finish, fail };
}
beforeEach(() => {
localStorage.clear();
mocks.getCode.mockResolvedValue(CODE);
});
it('offers them while the background frame is still booting, then the real list', async () => {
// A slot frame used to start only after the plugin's own frame had booted.
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
const { finish } = startingBackground();
const load = loadSandboxedPlugin(plugin(perms));
await vi.waitFor(() => expect(offersForSlot('sidebar-section').map((o) => o.pluginId)).toEqual(['managed-plugin']));
expect(lastStatus()?.status).not.toBe('running');
finish({ hooks: [], slots: [{ name: 'sidebar-section', hasShouldShow: true, order: 5 }], shortcuts: [] });
await load;
expect(offersForSlot('sidebar-section')).toEqual([{ pluginId: 'managed-plugin', order: 5, hasShouldShow: true }]);
expect(lastStatus()).toMatchObject({ status: 'running' });
});
it('remembers the list for the bundle it came from only', async () => {
mocks.createBackgroundInstance.mockImplementation(() => ({ ...fakeBackground(), initPromise: Promise.resolve({ hooks: [], slots: OFFERS, shortcuts: [] }) }));
await loadSandboxedPlugin(plugin(perms));
expect(JSON.parse(localStorage.getItem('plugin-slot-offers:managed-plugin')!)).toEqual({ hash: HASH, slots: OFFERS });
unloadSandboxedPlugin('managed-plugin');
// Another bundle: wait for its own list.
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: 'f'.repeat(64), slots: OFFERS }));
const { fail } = startingBackground();
const load = loadSandboxedPlugin(plugin(perms));
await new Promise((r) => setTimeout(r, 20));
expect(offersForSlot('sidebar-section')).toEqual([]);
fail(new Error('done'));
await load;
});
it('does not offer a remembered slot the plugin may not use', async () => {
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
const { fail } = startingBackground();
const load = loadSandboxedPlugin(plugin());
await new Promise((r) => setTimeout(r, 20));
expect(offersForSlot('sidebar-section')).toEqual([]);
fail(new Error('done'));
await load;
});
it('takes them down when the plugin fails to start', async () => {
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
const { fail } = startingBackground();
const load = loadSandboxedPlugin(plugin(perms));
await vi.waitFor(() => expect(offersForSlot('sidebar-section')).toHaveLength(1));
fail(new Error('boom'));
await load;
expect(offersForSlot('sidebar-section')).toEqual([]);
expect(getActive('managed-plugin')).toBeUndefined();
expect(lastStatus()).toMatchObject({ status: 'error' });
});
it('is not marked failed when it was turned off while starting', async () => {
localStorage.setItem('plugin-slot-offers:managed-plugin', JSON.stringify({ hash: HASH, slots: OFFERS }));
const { fail } = startingBackground();
const load = loadSandboxedPlugin(plugin(perms));
await vi.waitFor(() => expect(getActive('managed-plugin')).toBeDefined());
unloadSandboxedPlugin('managed-plugin');
// What destroy() does to a starting frame.
fail(new Error('sandbox destroyed'));
await load;
expect(lastStatus()).toMatchObject({ status: 'disabled' });
});
});
+3
View File
@@ -28,6 +28,7 @@ vi.mock('@/lib/plugin-loader', () => ({
setPluginStoreAccessor: vi.fn(),
setupAutoDisable: vi.fn(),
setSandboxLocale: vi.fn(),
forgetSlotOffers: vi.fn(),
}));
vi.mock('@/lib/plugin-hooks', () => ({
@@ -60,6 +61,7 @@ import { register as registerActive, deregister as deregisterActive, get as getA
// Import after mocks
import { usePluginStore, PLUGIN_PERSIST_DELAY_MS } from '@/stores/plugin-store';
import { pluginStorage } from '@/lib/plugin-storage';
import { forgetSlotOffers } from '@/lib/plugin-loader';
function resetStore() {
usePluginStore.setState({
@@ -460,6 +462,7 @@ describe('plugins the admin removed', () => {
expect(usePluginStore.getState().plugins.some((p) => p.id === 'gone')).toBe(false);
expect(localStorage.getItem('plugin:gone:@acct:notes')).toBeNull();
expect(pluginStorage.deletePreview).toHaveBeenCalledWith('gone');
expect(forgetSlotOffers).toHaveBeenCalledWith('gone');
});
});
+1
View File
@@ -11,6 +11,7 @@ vi.mock('@/lib/plugin-loader', () => ({
setPluginStoreAccessor: vi.fn(),
setupAutoDisable: vi.fn(),
setSandboxLocale: vi.fn(),
forgetSlotOffers: vi.fn(),
}));
import { fetchServerPluginList } from '@/lib/server-plugin-list';
+1 -1
View File
@@ -17,7 +17,7 @@ import { all as allActive, get as getActive } from './plugin-sandbox/registry';
// Re-export so the plugin store can keep the sandbox locale in step via this
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
// also pull the hook buses into consumers' module graphs).
export { setSandboxLocale } from './plugin-sandbox/loader';
export { setSandboxLocale, forgetSlotOffers } from './plugin-sandbox/loader';
export { snapshotRegionalSettings, type RegionalSnapshot } from './plugin-sandbox/host-bridge';
/**
+19
View File
@@ -290,6 +290,8 @@ export class SandboxInstance {
private rejectInit!: (err: Error) => void;
private listener: (ev: MessageEvent) => void;
private destroyed = false;
/** The frame has answered init-done: its hooks and slots are in place. */
private initialized = false;
private pendingHookInvokes = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
private pendingShouldShow = new Map<string, (show: boolean) => void>();
@@ -326,6 +328,8 @@ export class SandboxInstance {
this.resolveInit = res;
this.rejectInit = rej;
});
// Nobody waits on a slot frame's init; its rejection is not an error.
this.initPromise.catch(() => {});
this.iframe = document.createElement('iframe');
// Privileged tier: same-origin in BOTH dev and prod so the iframe gets real
@@ -451,6 +455,7 @@ export class SandboxInstance {
return;
case 'init-done':
this.initialized = true;
if (this.mode === 'slot') this.iframe.style.visibility = '';
this.resolveInit({ hooks: msg.hooks, slots: msg.slots, shortcuts: msg.shortcuts ?? [] });
return;
@@ -546,6 +551,10 @@ export class SandboxInstance {
/** Dispatch a hook handler inside the sandbox; resolves with its return value. */
invokeHook(hookName: string, args: unknown[]): Promise<unknown> {
if (this.destroyed) return Promise.reject(new Error('sandbox destroyed'));
// A plugin offering remembered slots is in the registry before its frame
// has booted; a click on one of its actions waits for it (a message
// posted before init is lost).
if (!this.initialized) return this.initPromise.then(() => this.invokeHook(hookName, args));
const id = uid();
const p = new Promise<unknown>((resolve, reject) => {
// The bus gives up on a slow handler, but the entry used to stay here
@@ -571,6 +580,9 @@ export class SandboxInstance {
/** Ask the background instance whether a slot should mount for this context. */
evaluateShouldShow(slot: SlotName, context: unknown): Promise<boolean> {
if (this.destroyed) return Promise.resolve(false);
if (!this.initialized) {
return this.initPromise.then(() => this.evaluateShouldShow(slot, context), () => false);
}
const id = uid();
const p = new Promise<boolean>((resolve) => {
this.pendingShouldShow.set(id, resolve);
@@ -649,9 +661,16 @@ export class SandboxInstance {
this.send(msg);
}
/** True once destroy() ran. */
get closed(): boolean {
return this.destroyed;
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
// Whoever waits for init (the loader, calls held back below) stops waiting.
this.rejectInit(new Error('sandbox destroyed'));
liveInstances.delete(this);
window.removeEventListener('message', this.listener);
this.iframe.remove();
+67 -14
View File
@@ -18,9 +18,9 @@ import { useEmailStore } from '@/stores/email-store';
import { emailToReadView } from '../plugin-projection';
import { verifyBundle } from './bundle-integrity';
import { downloadManagedBundle } from './bundle-fetch';
import { createBackgroundInstance, forEachInstance, snapshotRegionalSettings, type RegionalSnapshot } from './host-bridge';
import { createBackgroundInstance, forEachInstance, snapshotRegionalSettings, type InitDoneInfo, type RegionalSnapshot } from './host-bridge';
import { resolvePluginTier } from './tier';
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive } from './registry';
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries, get as getActive, type SlotOffer } from './registry';
import { cancelPluginDialogs } from './host-api';
import { hasDialogFor, whenNoDialogFor } from './host-dialog';
import { registerShortcuts } from './shortcuts';
@@ -98,7 +98,7 @@ async function readCachedBundle(plugin: InstalledPlugin): Promise<string | null>
}
}
async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
async function getBundleCode(plugin: InstalledPlugin): Promise<{ code: string; hash: string }> {
// IndexedDB is the fast path: the store's server sync (managed plugins) and
// the upload flow (user plugins) both populate it. It is per-browser though,
// while a managed plugin's record travels with the server registry to every
@@ -111,8 +111,7 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
const cached = await readCachedBundle(plugin);
if (cached) {
try {
await verifyBundle(cached, plugin.bundleHash);
return cached;
return { code: cached, hash: await verifyBundle(cached, plugin.bundleHash) };
} catch (err) {
if (!plugin.managed) throw err;
// Stale (sync updated the record but not the bytes) or corrupt copy; the
@@ -127,14 +126,60 @@ async function getBundleCode(plugin: InstalledPlugin): Promise<string> {
const code = await downloadManagedBundle(plugin.id, plugin.bundleHash, {
requireSignature: plugin.tier === 'privileged',
});
await verifyBundle(code, plugin.bundleHash);
const hash = await verifyBundle(code, plugin.bundleHash);
try {
await pluginStorage.saveCode(plugin.id, code);
} catch (err) {
// Caching is best effort - the plugin runs from the fetched bytes either way.
console.warn(`[plugin-sandbox] Could not cache bundle for "${plugin.id}":`, err);
}
return code;
return { code, hash };
}
// ─── Slot offers from the last start ─────────────────────────
// A plugin's slots are fixed exports of its bundle. They used to be known
// only once its background frame had booted and run activate(), so a slot
// frame (a sidebar section, say) started ~1 s after the plugin did, behind
// it. The offers are kept per bundle hash, and a plugin started with the
// same verified bundle offers them right away; its frames then boot next to
// the background one. The real list replaces them when init is done. The
// record only says where to draw: the same permission filter applies and
// the frames run the same verified code. A changed bundle has another hash.
const SLOT_CACHE_PREFIX = 'plugin-slot-offers:';
function readSlotOffers(pluginId: string, hash: string): InitDoneInfo['slots'] | null {
try {
const raw = localStorage.getItem(SLOT_CACHE_PREFIX + pluginId);
if (!raw) return null;
const rec = JSON.parse(raw) as { hash?: unknown; slots?: unknown };
if (rec.hash !== hash || !Array.isArray(rec.slots)) return null;
return rec.slots.filter((o): o is InitDoneInfo['slots'][number] =>
!!o && typeof o === 'object' && typeof (o as { name?: unknown }).name === 'string');
} catch {
return null;
}
}
function writeSlotOffers(pluginId: string, hash: string, slots: InitDoneInfo['slots']): void {
try {
localStorage.setItem(SLOT_CACHE_PREFIX + pluginId, JSON.stringify({ hash, slots }));
} catch {
// Storage blocked or full: the next start just waits for init again.
}
}
/** Forget a plugin's remembered slots (it was removed). */
export function forgetSlotOffers(pluginId: string): void {
try { localStorage.removeItem(SLOT_CACHE_PREFIX + pluginId); } catch { /* ignore */ }
}
function permittedOffers(plugin: InstalledPlugin, slots: InitDoneInfo['slots'], warn: boolean): SlotOffer[] {
return slots.filter((offer) => {
if (mayOfferSlot(plugin, offer.name)) return true;
if (warn) console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`);
return false;
});
}
// ─── Load ─────────────────────────────────────────────────────
@@ -166,9 +211,11 @@ const loading = new Map<string, Promise<void>>();
*/
export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
if (typeof window === 'undefined') return Promise.resolve();
if (getActive(plugin.id)) return Promise.resolve();
// Before the registry: a plugin offering remembered slots is registered
// while it is still starting.
const pending = loading.get(plugin.id);
if (pending) return pending;
if (getActive(plugin.id)) return Promise.resolve();
const run = startSandboxedPlugin(plugin).finally(() => loading.delete(plugin.id));
loading.set(plugin.id, run);
return run;
@@ -188,7 +235,7 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
}
const tier = resolution.tier;
const code = await getBundleCode(plugin);
const { code, hash } = await getBundleCode(plugin);
background = createBackgroundInstance({
plugin,
code,
@@ -196,6 +243,11 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
tier,
});
const remembered = readSlotOffers(plugin.id, hash);
if (remembered && remembered.length > 0) {
registerActive({ plugin, code, tier, background, slotOffers: permittedOffers(plugin, remembered, false), hookDisposables: [] });
}
// Wait for the background runtime to evaluate the bundle, register hooks,
// and enumerate slots. Bounded so a stuck iframe doesn't hang activation.
const bg = background;
@@ -244,11 +296,8 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
}
}
const slotOffers = info.slots.filter((offer) => {
if (mayOfferSlot(plugin, offer.name)) return true;
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`);
return false;
});
const slotOffers = permittedOffers(plugin, info.slots, true);
writeSlotOffers(plugin.id, hash, info.slots);
registerActive({
plugin,
@@ -270,6 +319,10 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
}
console.info(`[plugin-sandbox] "${plugin.id}" activated (hooks=${info.hooks.length}, slots=${info.slots.length})`);
} catch (err) {
// Turned off while it was starting: unload has set the status.
if (background?.closed && getActive(plugin.id)?.background !== background) return;
// Offered from the last start: take the slots back down.
if (background && getActive(plugin.id)?.background === background) deregisterActive(plugin.id);
const msg = (err as Error).message ?? String(err);
storeAccessor?.setPluginStatus(plugin.id, 'error', msg);
console.error(`[plugin-sandbox] Failed to load "${plugin.id}":`, err);
+2 -1
View File
@@ -7,7 +7,7 @@ import { persist, type PersistStorage, type StorageValue } from 'zustand/middlew
import type { InstalledPlugin, PluginStatus, PluginTier } from '@/lib/plugin-types';
import { pluginStorage } from '@/lib/plugin-storage';
import { extractPlugin } from '@/lib/plugin-validator';
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader';
import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale, forgetSlotOffers } from '@/lib/plugin-loader';
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { getEffectiveLocale } from '@/i18n/detect-locale';
@@ -421,6 +421,7 @@ export const usePluginStore = create<PluginStoreState>()(
function removePluginData(id: string): void {
deactivatePlugin(id);
removeAllPluginHooks(id);
forgetSlotOffers(id);
void pluginStorage.deleteCode(id).catch(() => {});
void pluginStorage.deletePreview(id).catch(() => {});
// Accounts the user connected for it live on the server (this mail account's).