diff --git a/lib/__tests__/plugin-sandbox-build.test.ts b/lib/__tests__/plugin-sandbox-build.test.ts index 71c6128df..a223592a8 100644 --- a/lib/__tests__/plugin-sandbox-build.test.ts +++ b/lib/__tests__/plugin-sandbox-build.test.ts @@ -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; 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); }); diff --git a/lib/__tests__/plugin-sandbox-document.test.ts b/lib/__tests__/plugin-sandbox-document.test.ts index 22f19d8d4..de91f87e9 100644 --- a/lib/__tests__/plugin-sandbox-document.test.ts +++ b/lib/__tests__/plugin-sandbox-document.test.ts @@ -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(); diff --git a/lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts b/lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts index 3990f1a5c..a49205ac5 100644 --- a/lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts +++ b/lib/__tests__/plugin-sandbox-runtime-slot-props.test.ts @@ -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); +}); diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx index 9423f1623..e121a8764 100644 --- a/lib/__tests__/plugin-ui-kit.test.tsx +++ b/lib/__tests__/plugin-ui-kit.test.tsx @@ -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; + + 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', () => { diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 35e93a89a..db5a39018 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -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; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 1416cc187..336996c39 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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 | null): (name: string) => unknown { +export function makePluginRequire(api: ReturnType | null, renderer: Renderer | null = null): (name: string) => unknown { + const reactDom = renderer?.ReactDOM ?? backgroundReactDom; const known: Record = { '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 | 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>; +} + +let rendererPromise: Promise | null = null; + +export function loadRenderer(): Promise { + rendererPromise ??= import('./slot-renderer') + .then((m) => ({ ReactDOM: m.ReactDOM, kit: m.pluginUiKit, SlotPropsContext: m.SlotPropsContext })); + return rendererPromise; +} + +function evaluateBundle(code: string, api: ReturnType | 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 | 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 { sendToHost({ type: 'init-done', hooks: hookNames, slots: slotInfo, shortcuts: shortcutInfo }); } -function bootSlot(payload: SlotInit): void { +async function bootSlot(payload: SlotInit): Promise { // 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; + // Props the host pushed while the renderer was loading replace the init ones. + let currentProps = decodeCallbacks(propsBeforeBoot ?? payload.extraProps) as Record; + 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) => void) | null = null; +/** The last props-update that arrived before bootSlot was ready for it. */ +let propsBeforeBoot: Record | null = null; async function handleInit(payload: InitPayload): Promise { if (bootDone) return; @@ -1166,7 +1195,7 @@ async function handleInit(payload: InitPayload): Promise { 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( diff --git a/lib/plugin-sandbox/sandbox-document.ts b/lib/plugin-sandbox/sandbox-document.ts index 62bcc1cc9..2254ca17e 100644 --- a/lib/plugin-sandbox/sandbox-document.ts +++ b/lib/plugin-sandbox/sandbox-document.ts @@ -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 | null = null; @@ -30,8 +32,8 @@ function loadAssets(): Promise { .then((text) => { const m = JSON.parse(text) as Partial; 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 { } 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 = '' + 'Plugin sandbox' + `` - + assets.preload.map((p) => ``).join('') + + preload.map((p) => ``).join('') + `` + '
'; return new Response(html, { diff --git a/lib/plugin-sandbox/slot-renderer.ts b/lib/plugin-sandbox/slot-renderer.ts new file mode 100644 index 000000000..462891c5e --- /dev/null +++ b/lib/plugin-sandbox/slot-renderer.ts @@ -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 }; diff --git a/lib/plugin-sandbox/ui-kit/background-kit.ts b/lib/plugin-sandbox/ui-kit/background-kit.ts new file mode 100644 index 000000000..5f0319920 --- /dev/null +++ b/lib/plugin-sandbox/ui-kit/background-kit.ts @@ -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> = Object.freeze(Object.fromEntries( + KIT_NAMES.map((name) => [name, name === 'KIT_VERSION' ? KIT_VERSION : unavailable(`@plugin-host/ui ${name}`)]), +)); + +export const backgroundReactDom: Readonly> = 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), +}); diff --git a/lib/plugin-sandbox/ui-kit/index.ts b/lib/plugin-sandbox/ui-kit/index.ts index 18dc6492a..bdac83a92 100644 --- a/lib/plugin-sandbox/ui-kit/index.ts +++ b/lib/plugin-sandbox/ui-kit/index.ts @@ -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, diff --git a/lib/plugin-sandbox/ui-kit/names.ts b/lib/plugin-sandbox/ui-kit/names.ts new file mode 100644 index 000000000..e350e9093 --- /dev/null +++ b/lib/plugin-sandbox/ui-kit/names.ts @@ -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', +]); diff --git a/scripts/build-plugin-sandbox.mjs b/scripts/build-plugin-sandbox.mjs index a1a0f7a6b..714e40357 100644 --- a/scripts/build-plugin-sandbox.mjs +++ b/scripts/build-plugin-sandbox.mjs @@ -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 };