mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
perf: load React DOM and the UI kit only in plugin frames that draw
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user