perf: load React DOM and the UI kit only in plugin frames that draw

This commit is contained in:
Linus Rath
2026-10-25 02:56:14 +01:00
parent a7cfa5b160
commit 171fd1ea4c
12 changed files with 195 additions and 36 deletions
+19 -3
View File
@@ -3,6 +3,7 @@ import { describe, it, expect, afterAll } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { buildPluginSandbox } from '../../scripts/build-plugin-sandbox.mjs';
type Outputs = Record<string, { bytes: number; entryPoint?: string; inputs: Record<string, unknown>; imports: Array<{ path: string; kind: string }> }>;
@@ -31,8 +32,10 @@ describe('plugin sandbox build', () => {
const eagerBytes = [...eager].reduce((sum, n) => sum + byName.get(n)!.bytes, 0);
const eagerInputs = [...eager].flatMap((n) => Object.keys(byName.get(n)!.inputs)).map((f) => f.replace(/\\/g, '/'));
// The first build put all 5000 Tabler icons (2.6 MB) in an eager chunk.
expect(eagerBytes).toBeLessThan(600_000);
// Background frames: the runtime and React, no React DOM or kit. (The
// first build put all 5000 Tabler icons, 2.6 MB, in an eager chunk.)
expect(eagerBytes).toBeLessThan(100_000);
expect(eagerInputs.filter((f) => f.includes('node_modules/react-dom/') || f.includes('ui-kit/components'))).toEqual([]);
expect(eagerInputs.filter((f) => f.includes('node_modules/next/'))).toEqual([]);
expect(eagerInputs.filter((f) => /(^|\/)stores\//.test(f))).toEqual([]);
// ...but they are still there for an icon name the kit doesn't ship.
@@ -40,7 +43,20 @@ describe('plugin sandbox build', () => {
expect(lazy.some((n) => byName.get(n)!.bytes > 1_000_000)).toBe(true);
const manifest = JSON.parse(await readFile(path.join(outdir, 'manifest.json'), 'utf8'));
expect(manifest).toEqual({ script, style, preload: [...eager].filter((n) => n !== script) });
expect(manifest).toMatchObject({ script, style, preload: [...eager].filter((n) => n !== script) });
// Slot frames preload React DOM and the kit, but not every icon.
const slotInputs = (manifest.slotPreload as string[]).flatMap((n) => Object.keys(byName.get(n)!.inputs)).map((f) => f.replace(/\\/g, '/'));
expect(slotInputs.some((f) => f.includes('node_modules/react-dom/'))).toBe(true);
expect(slotInputs.some((f) => f.includes('ui-kit/components'))).toBe(true);
expect((manifest.slotPreload as string[]).every((n) => byName.get(n)!.bytes < 1_000_000)).toBe(true);
expect(await readFile(path.join(outdir, style), 'utf8')).toContain('.bk');
// What a slot frame imports on demand, as the browser will get it: Vitest's
// own module interop once hid a createRoot that the bundle had lost.
const rendererChunk = byName.get(script)!.imports.find((i) => i.kind === 'dynamic-import')!;
const renderer = await import(/* @vite-ignore */ pathToFileURL(path.join(outdir, path.basename(rendererChunk.path))).href);
expect(typeof renderer.ReactDOM.createRoot).toBe('function');
expect(typeof renderer.pluginUiKit.Button).toBe('object');
expect(renderer.SlotPropsContext).toBeDefined();
}, 120_000);
});
@@ -22,7 +22,7 @@ function request(nonce = 'n0nce'): Request {
beforeEach(() => {
files.clear();
files.set(MANIFEST, JSON.stringify({ script: 'runtime-ABC.js', style: 'kit-123.css', preload: ['chunk-ICONS.js'] }));
files.set(MANIFEST, JSON.stringify({ script: 'runtime-ABC.js', style: 'kit-123.css', preload: ['chunk-ICONS.js'], slotPreload: ['chunk-DOM.js'] }));
vi.stubEnv('NODE_ENV', 'development'); // re-read the manifest for every test
});
@@ -48,6 +48,14 @@ describe('plugin sandbox document', () => {
expect(html).not.toContain('/_next/');
});
it('preloads React DOM and the kit for slot frames only', async () => {
const slot = await (await untrusted(new Request('http://localhost:3000/plugin-sandbox?frame=slot', { headers: { 'x-nonce': 'n' } }))).text();
const background = await (await untrusted(request())).text();
expect(slot).toContain('href="/plugin-sandbox-assets/chunk-DOM.js"');
expect(background).not.toContain('chunk-DOM.js');
});
it('serves the assets under the base path', async () => {
vi.stubEnv('NEXT_PUBLIC_BASE_PATH', '/webmail');
const html = await (await untrusted(request())).text();
@@ -123,3 +123,24 @@ describe('translations in the sandbox runtime', () => {
delete g.__pluginApi;
}, 30_000);
});
describe('slot boot', () => {
it('shows props the host pushed while React DOM and the kit were loading', async () => {
const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime');
render(React.createElement(SandboxRuntime));
post(hostWindow, HOST_ORIGIN, {
type: 'init',
payload: {
mode: 'slot', pluginId: 'x', tier: 'privileged', slot: 'email-banner', locale: 'en',
manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} },
code: BUNDLE, extraProps: { label: 'old' },
theme: { dark: false, fontFamily: 'sans-serif', vars: {} },
},
});
// Right away: the renderer is still loading.
post(hostWindow, HOST_ORIGIN, { type: 'props-update', props: { label: 'new' } });
await waitFor(() => expect(screen.getByText('new')).toBeInTheDocument());
expect(hostWindow.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'init-done' }), HOST_ORIGIN);
}, 30_000);
});
+18 -2
View File
@@ -149,12 +149,28 @@ describe('plugin UI kit components', () => {
});
it('is what the sandbox hands a plugin for require("@plugin-host/ui")', async () => {
const { makePluginRequire } = await import('../plugin-sandbox/runtime');
const req = makePluginRequire(null);
const { makePluginRequire, loadRenderer } = await import('../plugin-sandbox/runtime');
const req = makePluginRequire(null, await loadRenderer());
expect(req('@plugin-host/ui')).toBe(kit.pluginUiKit);
expect(kit.KIT_EXPORTS).toEqual(expect.arrayContaining(['Button', 'Input', 'Select', 'Combobox', 'Checkbox', 'Field', 'Tabs', 'List', 'ListRow', 'Badge', 'EmptyState', 'PageHeader', 'Spinner', 'DialogButtons', 'InfoBar', 'Chip']));
expect(() => req('left-pad')).toThrow(/not available/);
});
it('has stand-ins with the same names in a background frame, which draws nothing', async () => {
// Loading the kit and React DOM there cost every background frame ~350 kB of parsing.
const { makePluginRequire } = await import('../plugin-sandbox/runtime');
const { KIT_NAMES } = await import('../plugin-sandbox/ui-kit/names');
const req = makePluginRequire(null);
const ui = req('@plugin-host/ui') as Record<string, unknown>;
expect([...KIT_NAMES].sort()).toEqual(kit.KIT_EXPORTS);
expect(Object.keys(ui).sort()).toEqual(kit.KIT_EXPORTS);
expect(ui.KIT_VERSION).toBe(kit.KIT_VERSION);
expect(() => (ui.Button as () => void)()).toThrow(/only works in a slot frame/);
const { createPortal } = req('react-dom') as { createPortal: () => void };
expect(() => createPortal()).toThrow(/only works in a slot frame/);
expect((req('react-dom') as { then?: unknown }).then).toBeUndefined();
});
});
describe('DialogButtons', () => {
+7 -1
View File
@@ -392,7 +392,10 @@ export class SandboxInstance {
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
// Privileged plugins load the same-origin route so the CSP/allow-same-origin
// pairing is consistent.
this.iframe.src = withBasePath(this.privileged ? SANDBOX_PRIVILEGED_PATH : SANDBOX_PATH);
// ?frame=slot: the document then loads React DOM and the UI kit with the
// runtime; a background frame never needs them (sandbox-document.ts).
this.iframe.src = withBasePath(this.privileged ? SANDBOX_PRIVILEGED_PATH : SANDBOX_PATH)
+ (initPayload.mode === 'slot' ? '?frame=slot' : '');
this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener);
@@ -465,6 +468,9 @@ export class SandboxInstance {
return;
case 'init-error':
// The loader reports a background frame's failure; nobody waits on a
// slot frame's init, so say it here or it fails without a trace.
if (this.mode === 'slot') console.error(`[plugin-sandbox] "${this.pluginId}" slot failed to start: ${msg.error}`);
this.rejectInit(new Error(msg.error));
return;
+47 -16
View File
@@ -18,7 +18,6 @@
import { useEffect } from 'react';
import * as React from 'react';
import * as ReactDOM from 'react-dom/client';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import type {
HostToSandbox,
@@ -29,7 +28,7 @@ import type {
RegionalSnapshot,
} from './protocol';
import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme';
import { pluginUiKit, SlotPropsContext } from './ui-kit';
import { backgroundReactDom, backgroundUiKit } from './ui-kit/background-kit';
import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui-kit/floating-dropdown-bridge';
import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n';
import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
@@ -956,27 +955,51 @@ function buildPluginApi(manifest: PluginManifest) {
* The host injects the per-plugin API as `@plugin-host`, so plugin code can
* `const api = require('@plugin-host')` in both background and slot modes.
*/
export function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null): (name: string) => unknown {
export function makePluginRequire(api: ReturnType<typeof buildPluginApi> | null, renderer: Renderer | null = null): (name: string) => unknown {
const reactDom = renderer?.ReactDOM ?? backgroundReactDom;
const known: Record<string, unknown> = {
'react': React,
'react-dom': ReactDOM,
'react-dom/client': ReactDOM,
'react-dom': reactDom,
'react-dom/client': reactDom,
'react/jsx-runtime': ReactJSXRuntime,
'react/jsx-dev-runtime': ReactJSXRuntime,
};
if (api) known['@plugin-host'] = api;
// The app's controls (lib/plugin-sandbox/ui-kit), so plugins draw the same
// buttons, fields and lists as the app without bundling their own.
known['@plugin-host/ui'] = pluginUiKit;
// buttons, fields and lists as the app without bundling their own. In a
// background frame, stand-ins with the same names.
known['@plugin-host/ui'] = renderer?.kit ?? backgroundUiKit;
return (name: string) => {
if (Object.prototype.hasOwnProperty.call(known, name)) return known[name];
throw new Error(`Plugin sandbox: module "${name}" is not available. Externalise it in your bundler or ship it bundled.`);
};
}
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null): PluginExports {
// ─── Renderer (slot frames only) ─────────────────────────────
/**
* React DOM and the UI kit. Only slot frames draw, so only they load these
* (a separate chunk, ~350 kB to parse); a background frame gets React and
* stand-ins. The host marks slot frames in their URL (?frame=slot) so the
* load starts with the document, not after init.
*/
export interface Renderer {
ReactDOM: typeof import('react-dom/client');
kit: unknown;
SlotPropsContext: React.Context<Record<string, unknown>>;
}
let rendererPromise: Promise<Renderer> | null = null;
export function loadRenderer(): Promise<Renderer> {
rendererPromise ??= import('./slot-renderer')
.then((m) => ({ ReactDOM: m.ReactDOM, kit: m.pluginUiKit, SlotPropsContext: m.SlotPropsContext }));
return rendererPromise;
}
function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | null, renderer: Renderer | null = null): PluginExports {
const mod: { exports: PluginExports } = { exports: {} };
const requireShim = makePluginRequire(api);
const requireShim = makePluginRequire(api, renderer);
let fn: (...args: unknown[]) => void;
try {
fn = new Function(
@@ -987,7 +1010,7 @@ function evaluateBundle(code: string, api: ReturnType<typeof buildPluginApi> | n
throw new Error(`Bundle parse error: ${(err as Error).message}`);
}
try {
fn(mod, mod.exports, requireShim, React, ReactDOM, ReactJSXRuntime, console);
fn(mod, mod.exports, requireShim, React, renderer?.ReactDOM ?? backgroundReactDom, ReactJSXRuntime, console);
} catch (err) {
throw new Error(`Bundle evaluation threw: ${(err as Error).message}`);
}
@@ -1056,13 +1079,15 @@ async function bootBackground(payload: BackgroundInit): Promise<void> {
sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo });
}
function bootSlot(payload: SlotInit): void {
async function bootSlot(payload: SlotInit): Promise<void> {
// Replay the host theme before first paint so the slot never flashes the UA
// default serif font or a light-on-light/dark mismatch.
applyHostTheme(payload.theme);
const renderer = await loadRenderer();
const { SlotPropsContext } = renderer;
const api = buildPluginApi(payload.manifest);
const exports = evaluateBundle(payload.code, api);
const exports = evaluateBundle(payload.code, api, renderer);
pluginExports = exports;
slotName = payload.slot;
@@ -1074,7 +1099,9 @@ function bootSlot(payload: SlotInit): void {
const rootEl = document.getElementById('plugin-sandbox-root');
if (!rootEl) throw new Error('Sandbox root element missing');
let currentProps = decodeCallbacks(payload.extraProps) as Record<string, unknown>;
// Props the host pushed while the renderer was loading replace the init ones.
let currentProps = decodeCallbacks(propsBeforeBoot ?? payload.extraProps) as Record<string, unknown>;
propsBeforeBoot = null;
const Component = slotDef.component;
const inline = isInlineSlot(payload.slot);
// Filling its panel: the document is as tall as the frame and the slot's
@@ -1147,13 +1174,15 @@ function bootSlot(payload: SlotInit): void {
React.createElement(SlotPropsContext.Provider, { value: props }, React.createElement(Component, props)));
};
const reactRoot = ReactDOM.createRoot(rootEl);
const reactRoot = renderer.ReactDOM.createRoot(rootEl);
reactRoot.render(React.createElement(SlotShell));
sendToHost({ type: 'init-done', hooks: [], slots: [], shortcuts: [] });
}
// Populated by bootSlot - receives `props-update` messages.
let slotPropsUpdater: ((next: Record<string, unknown>) => void) | null = null;
/** The last props-update that arrived before bootSlot was ready for it. */
let propsBeforeBoot: Record<string, unknown> | null = null;
async function handleInit(payload: InitPayload): Promise<void> {
if (bootDone) return;
@@ -1166,7 +1195,7 @@ async function handleInit(payload: InitPayload): Promise<void> {
if (payload.mode === 'background') {
await bootBackground(payload);
} else {
bootSlot(payload);
await bootSlot(payload);
}
} catch (err) {
sendToHost({ type: 'init-error', error: (err as Error).message ?? String(err) });
@@ -1259,7 +1288,8 @@ function handleHostMessage(ev: MessageEvent): void {
break;
case 'props-update':
slotPropsUpdater?.(msg.props ?? {});
if (slotPropsUpdater) slotPropsUpdater(msg.props ?? {});
else if (mode === 'slot') propsBeforeBoot = msg.props ?? {};
break;
case 'stream-chunk': {
@@ -1308,6 +1338,7 @@ export function startSandboxRuntime(): () => void {
// nothing a window.open()-ing site could talk to.
const host = resolveHost();
if (!host) return () => {};
if (new URLSearchParams(window.location.search).get('frame') === 'slot') void loadRenderer();
parentWindow = host.window;
parentOrigin = host.origin;
initFloatingDropdownBridge(
+7 -3
View File
@@ -19,6 +19,8 @@ interface SandboxAssets {
style: string;
/** Chunks the script imports statically. */
preload: string[];
/** Chunks of the renderer (React DOM and the UI kit), for slot frames. */
slotPreload: string[];
}
let cached: Promise<SandboxAssets> | null = null;
@@ -30,8 +32,8 @@ function loadAssets(): Promise<SandboxAssets> {
.then((text) => {
const m = JSON.parse(text) as Partial<SandboxAssets>;
if (typeof m.script !== 'string' || typeof m.style !== 'string') throw new Error('bad manifest');
const preload = Array.isArray(m.preload) ? m.preload.filter((p): p is string => typeof p === 'string') : [];
return { script: m.script, style: m.style, preload };
const names = (v: unknown) => (Array.isArray(v) ? v.filter((p): p is string => typeof p === 'string') : []);
return { script: m.script, style: m.style, preload: names(m.preload), slotPreload: names(m.slotPreload) };
});
cached.catch(() => { cached = null; });
return cached;
@@ -51,10 +53,12 @@ export async function sandboxDocument(request: Request): Promise<Response> {
}
const nonce = escapeAttr(request.headers.get('x-nonce') ?? '');
const base = `${process.env.NEXT_PUBLIC_BASE_PATH ?? ''}${SANDBOX_ASSETS_PATH}`;
const slot = new URL(request.url).searchParams.get('frame') === 'slot';
const preload = slot ? [...assets.preload, ...assets.slotPreload] : assets.preload;
const html = '<!doctype html><html lang="en"><head><meta charset="utf-8">'
+ '<meta name="robots" content="noindex, nofollow"><title>Plugin sandbox</title>'
+ `<link rel="stylesheet" href="${escapeAttr(`${base}/${assets.style}`)}">`
+ assets.preload.map((p) => `<link rel="modulepreload" nonce="${nonce}" href="${escapeAttr(`${base}/${p}`)}">`).join('')
+ preload.map((p) => `<link rel="modulepreload" nonce="${nonce}" href="${escapeAttr(`${base}/${p}`)}">`).join('')
+ `<script type="module" nonce="${nonce}" src="${escapeAttr(`${base}/${assets.script}`)}"></script>`
+ '</head><body style="margin:0;padding:0;background:transparent"><div id="plugin-sandbox-root"></div></body></html>';
return new Response(html, {
+9
View File
@@ -0,0 +1,9 @@
// What only slot frames load (runtime.tsx loadRenderer): React DOM and the
// UI kit. Static imports here, one dynamic import of this module there: a
// dynamic import of react-dom/client itself left createRoot off the
// namespace once bundled (CommonJS interop).
import * as ReactDOM from 'react-dom/client';
import { pluginUiKit, SlotPropsContext } from './ui-kit';
export { ReactDOM, pluginUiKit, SlotPropsContext };
@@ -0,0 +1,22 @@
// What a plugin's background frame gets for `require('@plugin-host/ui')`
// and `require('react-dom')`. A background frame draws nothing, yet loading
// the real kit and React DOM made each one parse ~350 kB, a dozen times at
// start-up. Bundles still require them at the top (one bundle serves the
// background and the slots), so the names are all there; using one throws.
import { KIT_NAMES, KIT_VERSION } from './names';
function unavailable(what: string): () => never {
return () => {
throw new Error(`${what} only works in a slot frame; the background frame draws nothing`);
};
}
export const backgroundUiKit: Readonly<Record<string, unknown>> = Object.freeze(Object.fromEntries(
KIT_NAMES.map((name) => [name, name === 'KIT_VERSION' ? KIT_VERSION : unavailable(`@plugin-host/ui ${name}`)]),
));
export const backgroundReactDom: Readonly<Record<string, unknown>> = new Proxy(Object.freeze({}), {
// Not a promise or an ES module namespace, for interop helpers that check.
get: (_target, key) => (typeof key === 'string' && key !== 'then' && key !== '__esModule' ? unavailable(`react-dom ${key}`) : undefined),
});
+4 -3
View File
@@ -1,16 +1,17 @@
// `@plugin-host/ui`: the module plugins require for the app's controls.
// See components.tsx. Adding an export is a plugin API change: list it in
// KIT_EXPORTS below (the plugins repo's docs and fake API read that list).
// KIT_EXPORTS below (the plugins repo's docs and fake API read that list)
// and in names.ts.
import * as components from './components';
import { DialogButtons, useSlotProps } from './dialog-buttons';
import { KIT_VERSION } from './names';
export * from './components';
export { DialogButtons, SlotPropsContext, useSlotProps } from './dialog-buttons';
export type { DialogButtonSpec } from './dialog-buttons';
/** Bumped when a component is added or its props change in a way plugins can see. */
export const KIT_VERSION = 2;
export { KIT_VERSION };
export const pluginUiKit = Object.freeze({
...components,
+16
View File
@@ -0,0 +1,16 @@
// The names `require('@plugin-host/ui')` hands a plugin, and the kit's
// version. Kept apart from the components so a background frame
// (background-kit.ts) can offer the same names without loading them;
// plugin-ui-kit.test.ts checks this list against the kit.
/** Bumped when a component is added or its props change in a way plugins can see. */
export const KIT_VERSION = 2;
export const KIT_NAMES: readonly string[] = Object.freeze([
'Alert', 'Badge', 'BarButton', 'Breadcrumbs', 'Button', 'Card', 'Checkbox', 'Chip', 'ColorDot', 'Combobox',
'DetailHeading', 'DetailList', 'DetailRow', 'DialogButtons', 'DiffView', 'Divider', 'EmptyState', 'Field',
'FileIcon', 'Heading', 'Icon', 'InfoBar', 'Input', 'KIT_VERSION', 'Link', 'LinkButton', 'List', 'ListRow',
'Loading', 'Page', 'PageHeader', 'Panel', 'ProgressBar', 'Row', 'RowAction', 'SearchInput', 'Section',
'Segmented', 'Select', 'SettingRow', 'SidebarEmpty', 'SidebarRow', 'Spinner', 'Stack', 'Switch', 'Tabs',
'Text', 'Textarea', 'Toolbar', 'ToolbarButton', 'ToolbarSpacer', 'useSlotProps',
]);
+16 -7
View File
@@ -116,18 +116,27 @@ export async function buildPluginSandbox({ outdir = DEFAULT_OUTDIR, clean = true
// The chunks the script imports statically, so the document can fetch them
// with it instead of after it has been parsed.
const outputs = new Map(Object.entries(js.metafile.outputs).map(([f, o]) => [path.basename(f), o]));
const preload = [];
const visit = (name) => {
const closure = (name, into) => {
for (const imp of outputs.get(name)?.imports ?? []) {
const dep = path.basename(imp.path);
if (imp.kind !== 'import-statement' || preload.includes(dep)) continue;
preload.push(dep);
visit(dep);
if (imp.kind !== 'import-statement' || into.includes(dep)) continue;
into.push(dep);
closure(dep, into);
}
return into;
};
visit(path.basename(script));
const preload = closure(path.basename(script), []);
// What the runtime imports on demand (React DOM and the UI kit), which a
// slot frame loads right away; the all-icons chunk is the kit's, not here.
const slotPreload = [];
for (const imp of outputs.get(path.basename(script))?.imports ?? []) {
if (imp.kind !== 'dynamic-import') continue;
for (const dep of [path.basename(imp.path), ...closure(path.basename(imp.path), [])]) {
if (!preload.includes(dep) && !slotPreload.includes(dep)) slotPreload.push(dep);
}
}
const manifest = { script: path.basename(script), style, preload };
const manifest = { script: path.basename(script), style, preload, slotPreload };
await writeFile(path.join(outdir, 'manifest.json'), `${JSON.stringify(manifest)}\n`);
const inputs = [...Object.keys(js.metafile.inputs).map((f) => path.resolve(root, f)), cssPath, path.join(root, 'app/theme-inline.css')];
return { ...manifest, outputs: js.metafile.outputs, css: css.css, inputs };