@persona/layout-editor-remote-module
Version:
Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.
497 lines (400 loc) • 22.8 kB
Markdown
---
name: getting-started
description: >
Wire up @persona/layout-editor-remote-module in a Persona dashboard app: register
personaLayoutModules in vite.config.ts, author modules with defineLayoutModule using an .html
document entry, add a zod configSchema and an RJSF uiConfigSchema (labels, field order,
DataSourceField pickers), and talk to the host from the iframe with LayoutModuleBridge (layout
data, host context such as layoutType / layoutObjectId / isReadOnly / dynamicHeight,
resolveDataPath, height, loaded signal). Activate when adding a layout module, integrating this
package for the first time, wiring a module document to the dashboard host, or debugging a
manifest that fails to build or validate (duplicate key, missing defaultSize entry, render path
not found), a module that never receives data from the host, a host loading card that never
clears, or an iframe whose height collapses or keeps growing under dynamic height.
metadata:
type: core
library: '@persona/layout-editor-remote-module'
---
# Getting started with @persona/layout-editor-remote-module
Shared manifest format and Vite plugin for remotely-defined dashboard layout modules — lets Persona
dashboard apps declare a layout module directly in a typical app's `vite.config.ts`, without editing
the host. The plugin ensures each module's HTML document is built and emits a
`{ modules: RemoteModuleRegistrationManifestV1[] }` JSON document the host fetches and validates.
This package is publicly available from the npm registry.
## Install
```sh
yarn add @persona/layout-editor-remote-module
```
`vite` (`^6 || ^7 || ^8`) and `zod` are peer dependencies. Vite 5 is unsupported because the plugin
relies on the Vite 6 Environment API; `react` / `react-dom` (`^17 || ^18 || ^19`) are optional peers
needed only by the `bridge-frame` entry.
Build the iframed document against `@persona/layout-editor-remote-module` 0.5.3 or later. Older
frames reject any host message that carries a key they don't know, so they stop working once the
host sends new context fields such as `dynamicHeight`.
## Setup (vite.config.ts)
```ts
import { defineConfig } from 'vite';
import {
defineLayoutModule,
personaLayoutModules,
} from '@persona/layout-editor-remote-module/vite-plugin';
export default defineConfig({
plugins: [
personaLayoutModules({
modules: [
defineLayoutModule({
key: 'investigation_console',
displayName: 'Investigation Console',
descriptionShort: 'Full investigation workspace',
allowAddingToLayout: true,
layouts: ['case'],
defaultSize: { case: { width: 12, height: 40 } },
render: { entry: './index.html' },
}),
],
}),
],
});
```
An empty or omitted `layouts` makes the module available in every layout. When `layouts` lists
specific layouts, `defaultSize` must provide an entry for each one.
Optional fields: `allowDynamicHeight` (exposes the option only; the host sizes the iframe to the
module's reported height only when the user turns "dynamic height" on for that placement, and the
module learns this through [`context.dynamicHeight`](#host-context)), `badge`
(`'beta' | 'deprecated' | 'improved' | 'new'`), `category`
(`'build_your_own' | 'components' | 'management' | 'related_records'`; the host defaults to
`build_your_own`), `categoryCustom` (a free-form module-library category label, up to 80 characters;
the host renders it verbatim — it is not translated — and groups modules that share the same
trimmed, case-sensitive string, even across different module sources. When both are set,
`categoryCustom` overrides `category`, and a label equal to one of the host's built-in category
labels lands in "Other"; keep setting `category` as a fallback for hosts that predate
`categoryCustom`), `icon` (an image path relative to the module's origin, such as
`/assets/icon.svg`; the library shows a generic icon when it's omitted, fails to load, or is
rejected as a cross-origin or non-http(s) URL), `internalUsersOnly` (a hint the host may use to
narrow visibility, never widen it), and `configSchema` / `uiConfigSchema` (below).
`entry` is resolved relative to the Vite **root** (`config.root`, or `process.cwd()` when unset) —
not relative to `vite.config.ts`'s own directory when `root` is overridden elsewhere in the config.
In a multi-environment build (e.g. with `@cloudflare/vite-plugin`), the plugin adds module documents
only to the `client` environment's build inputs and writes the manifest only into the client
environment's `build.outDir`. Non-client environments (workers/SSR) never see the `.html` entries.
Source: `src/vite-plugin.ts` (`configEnvironment`, `closeBundle`).
## Core Patterns
### Document delivery — iframed page
A module's `render.entry` must end in `.html`: the host iframes the page. Any other extension fails
during Vite config resolution with a clear error. Use `path` for a client-routed SPA whose
`index.html` already renders the whole app — it names the logical route the SPA's own router should
land on, not the file's build output path:
```ts
defineLayoutModule({
key: 'investigation_console',
displayName: 'Investigation Console',
descriptionShort: 'Full investigation workspace',
allowAddingToLayout: true,
layouts: ['case'],
defaultSize: { case: { width: 12, height: 40 } },
render: { entry: './index.html', path: '/embed/investigations/console' },
});
```
If the `.html` file is already one of the app's own build entries (its main `index.html`), the
plugin detects that and doesn't inject it again.
### `configSchema` — validated module configuration
Author config as zod; the plugin converts it to JSON Schema for the host's config panel:
```ts
import { z } from 'zod';
defineLayoutModule({
layouts: ['case', 'account'],
configSchema: z.object({ ownerId: z.string().default('current_case.id') }),
defaultConfig: { account: { ownerId: 'current_account.id' } },
});
```
`defaultConfig` overrides top-level schema defaults for new placements. Each set key must validate
against `configSchema` and, when `layouts` is non-empty, be listed there. Keep `configSchema` to
plain data shapes; hosts predating `defaultConfig` ignore it, so retain a sensible schema default.
### `uiConfigSchema` — config panel layout
The host renders the config panel with RJSF. Pass an RJSF `uiSchema` as `uiConfigSchema`; it is
already plain data, so the plugin copies it into the manifest unchanged. Its top-level keys are the
`configSchema` property names, and each one holds that property's `ui:*` entries:
```ts
defineLayoutModule({
// ...
configSchema: z.object({
threshold: z.number().min(0).max(1).default(0.7),
title: z.string().optional(),
}),
uiConfigSchema: {
'ui:order': ['title', 'threshold'],
threshold: { 'ui:widget': 'range' },
},
});
```
- **Labels and help text** come from the property's JSON Schema `title` / `description` — set them
with zod's `.meta({ title, description })` — and fall back to the property name. Override them per
field with `'ui:options': { title, description }`; the description shows as the label's tooltip.
- **Field order** is `'ui:order'` at the top level (use `'*'` for "everything else").
- **Controls are picked from the schema** without a `uiConfigSchema` entry: an enum renders as a
dropdown, an array of enum values with `uniqueItems: true` in its JSON Schema as a multi-select, a
boolean as a checkbox, and an array of objects as a list whose items open in an editing panel.
- **Everything is JSON.** The manifest is static JSON, so function-valued RJSF options
(`createItem`, `itemLabel`, …) and custom React fields/widgets cannot be supplied by a module —
only the `ui:field` names and options the host already registers, listed below.
#### `DataSourceField` — pick a value from the record
`'ui:field': 'DataSourceField'` turns a property into the host's data-source input: an
autocompleting method-chain input (e.g. `current_case.id`, or `query.<variable>` for a layout
variable) checked against the platform schema. Declare the property as a **string** — the field then
stores the chain itself as a string:
```ts
defineLayoutModule({
// ...
layouts: ['account'],
defaultSize: { account: { width: 6, height: 24 } },
configSchema: z.object({
accountId: z.string().optional(),
}),
uiConfigSchema: {
accountId: {
'ui:field': 'DataSourceField',
'ui:options': {
title: 'Account ID',
description: 'Account whose transactions are monitored',
},
},
},
});
```
`ui:options` it reads:
- `title`, `description` — label and tooltip; default to the schema's `title` / `description`.
- `expectedSchemaType` — the platform object type the chain must resolve to (e.g. `'case'`,
`'user'`); a chain resolving to another type is flagged. Unset means any chain is accepted. An
empty value is never validated.
- `rootPath` — limits validation to chains under one top-level property (e.g. `'current_case'`).
Only set it when `layouts` names a single layout that has that root; a chain outside `rootPath`
still saves but is not validated.
The config holds the **chain, not its value**. Resolve it at render time with `resolveDataPath` from
`LayoutModuleBridge` (below), skipping an unset field:
```tsx
import { useEffect, useState } from 'react';
import {
type Json,
type LayoutModuleBridgeRenderProps,
} from '@persona/layout-editor-remote-module/bridge-frame';
function TransactionMonitor({
data,
resolveDataPath,
}: Pick<LayoutModuleBridgeRenderProps, 'data' | 'resolveDataPath'>) {
const chain = (data as { accountId?: string } | null)?.accountId;
const [accountId, setAccountId] = useState<Json>(null);
useEffect(() => {
setAccountId(null);
if (!chain) return;
let current = true;
resolveDataPath(chain).then(
(value) => current && setAccountId(value),
() => current && setAccountId(null),
);
return () => {
current = false;
};
}, [chain, resolveDataPath]);
// ...
}
```
The host pushes config updates into the mounted module, so the chain can change or be cleared at any
time: reset the resolved value whenever it does, and ignore responses to an outdated chain.
`resolveDataPath` resolves to `null` when the chain can't be resolved on the current record and
rejects on a host error or timeout, so handle both.
#### Other `ui:options`
- `errorMessages` — per-keyword validation messages, e.g.
`{ 'ui:options': { errorMessages: { pattern: 'Use letters only.' } } }` (keys are JSON Schema
keywords: `pattern`, `minLength`, `maximum`, …).
- On an enum multi-select: `unsetSelectsAll: true` shows an unset value as every option selected,
for a key whose absence means "all" and should stay absent.
- On an array of objects: `identifierField` (item property that labels each row), `emptyText`,
`editLabel` (the editing panel's title), `listAriaLabel`, and `inlineItems: true` to render the
items inline instead of as a list with an editing panel.
### `bridge-frame` — talking to the host from the iframed document
The host renders a document-delivered module in an iframe and speaks to it over `postMessage`. Wrap
the module's React root in `LayoutModuleBridge` from the `bridge-frame` entry; it owns the whole
handshake and hands the module a render prop:
```tsx
import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-frame';
export function App() {
return (
<LayoutModuleBridge>
{({ data, context, resolveDataPath }) => (
<RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} />
)}
</LayoutModuleBridge>
);
}
```
- `children` is only invoked once the host has sent the initial layout data — it never sees
`data: undefined`. Until then `fallback` (default `null`) is rendered, hidden under the host's
loading card. After the first render the bridge posts `loaded` and the host removes the card, so
render any data-loading state from `children`.
- `data` is the module's persisted config as JSON, and re-renders whenever the host pushes an
update.
- `context` (`RemoteModuleContext`) is where the module is rendered — host-owned facts the module
cannot derive itself. It arrives with the initial data and re-renders whenever it changes (see
below).
- `resolveDataPath(dataPath, variableMap?)` returns `Promise<Json>`; the host owns the
implementation. It rejects if the host reports an error or does not answer within
`resolveTimeoutMs` (default 30s). Outside an iframe it rejects immediately, so a module opened
directly in a tab still renders.
- Height is reported automatically: the bridge wraps `children` in a `display: flow-root` div and
reports the height the frame document needs, including `body` and ancestor margins, padding, and
borders, so the host can size the iframe (`observeHeight={false}` to opt out). The host uses the
reported height only when [`context.dynamicHeight`](#host-context) is true; otherwise, the iframe
keeps its grid size.
- A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown
after the host loading overlay is dismissed.
- Mount exactly one `LayoutModuleBridge` per document.
Props: `fallback?: ReactNode`, `observeHeight?: boolean` (default `true`),
`resolveTimeoutMs?: number`, `allowedHostOrigins?` (to add host origins outside the built-in
allow-list). Built-in origins include `*.withpersona.com`, `*.withpersona-staging.com`, `localhost`,
and `127.0.0.1`. Dev and preview dashboards must be added explicitly; compose the built-in predicate
with an app-specific check:
```tsx
import {
LayoutModuleBridge,
personaDashboardHostOrigins,
} from '@persona/layout-editor-remote-module/bridge-frame';
<LayoutModuleBridge
allowedHostOrigins={(origin) => personaDashboardHostOrigins(origin) || myDevHost(origin)}
>
{({ data }) => <RiskSummary data={data} />}
</LayoutModuleBridge>;
```
#### Host context
`RemoteModuleContext` (exported as a type from `bridge-frame`) has:
- `layoutType` — known values are `'account' | 'case' | 'transaction'`, but its type is
`LayoutType | (string & {})` and the bridge accepts layout types added after the module's release.
Handle unknown values with a default branch rather than a switch that tries to cover every case.
- `layoutObjectId` — the ID of the object the layout is rendered for (e.g. the viewed case's ID on a
`case` layout), or `null` when there is none.
- `isReadOnly` — the host object is locked for edits; hide or disable mutating actions.
- `isInEditor` — rendered inside the layout editor rather than on a live record.
- `isUsingStubData` — the host is feeding placeholder data instead of a real record; skip network
calls or side effects keyed off the record.
- `dynamicHeight` — `boolean`, optional in the type; `RemoteModuleFrame` always sets it. It is true
when the host sizes the iframe to the height the bridge reports. When true, size to content
(`height: auto`) instead of filling the viewport. When false or absent, the iframe has a fixed
grid height, so filling it (`100vh`) is fine. Treat a `null` context (not embedded) like false;
see
[Viewport-relative height under dynamic height](#high-viewport-relative-height-under-dynamic-height).
`context` is `null` only when the document is not embedded (opened directly in a tab); inside
`children` it is otherwise always set, because `children` runs only after `init`.
```tsx
import {
LayoutModuleBridge,
type RemoteModuleContext,
} from '@persona/layout-editor-remote-module/bridge-frame';
function RiskSummary({ context }: { context: RemoteModuleContext | null }) {
if (!context?.layoutObjectId) {
return <EmptyState />;
}
return <RiskScore caseId={context.layoutObjectId} readOnly={context.isReadOnly} />;
}
```
The protocol only ever adds fields. Receivers built on 0.5.3+ ignore unknown message types and strip
unknown keys, so new context fields may appear in later host releases without a frame upgrade.
Source: `src/bridge/protocol.ts`.
#### Access token and environment id
The host still appends `access_token`, `environment_id`, and `locale` to the iframe `src`. The app
hosting the module consumes and strips those parameters itself, so by the time `LayoutModuleBridge`
runs they have already been handled; the bridge neither reads nor strips them. Keep the host
iframe's `referrerpolicy` at its default `no-referrer` (or use `strict-origin`) so the URL
parameters cannot leak through subsequent requests.
## Common Mistakes
### [HIGH] Reading layout data outside `LayoutModuleBridge`
Do not invent APIs such as `requestDataSomehow()` or `window.resolveDataPath`. `data` and
`resolveDataPath` exist only in the `LayoutModuleBridge` render prop; pass them to nested components
from there.
Source: `src/bridge/iframe/LayoutModuleBridge.tsx`.
### [MEDIUM] Host loading card stays visible forever when embedded
The host removes its loading card once `LayoutModuleBridge` has rendered `children`, so a card that
never clears usually means the handshake didn't complete. Check, in order: the document is really
cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects unknown
parents — pass your own only for a non-Persona dev host); the frame's
`@persona/layout-editor-remote-module` is 0.5.3 or later (a frame on 0.5.2 or older rejects an
`init` whose context has keys it doesn't know, e.g. `dynamicHeight`, sent by hosts on 0.8.0+, so
`children` never runs); and the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s
never send `init`). For a direct `createModuleBridge` integration, also confirm that a bridge
created with `signalsLoaded: true` calls `signalLoaded()`. Opening the document directly in a tab is
_not_ embedded — `data` and `context` resolve to `null` and `children` renders.
Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`, `src/bridge/protocol.ts`.
### [HIGH] Viewport-relative height under dynamic height
Wrong: the module root uses `height: 100vh` or `min-height: 100vh` (or `height: 100%` on `html` /
`body` / root) regardless of context. With dynamic height on, the bridge reports the iframe's own
height, so the module can never shrink. And because the reported height includes the frame
document's `body`/ancestor margin and padding, `100vh` or `min-height: 100vh` combined with any body
margin or padding reports a height larger than the iframe, so the iframe grows on every report.
Correct: switch to `auto` when `context?.dynamicHeight` is true:
```tsx
const sizing = context?.dynamicHeight ? 'content' : 'viewport';
// root: height: sizing === 'content' ? 'auto' : '100vh'
// min-height: sizing === 'content' ? 'auto' : '100vh'
```
Source: `src/bridge/protocol.ts` (`remoteModuleContextSchema.dynamicHeight`),
`src/bridge/host/RemoteModuleFrame.tsx`.
### [HIGH] `DataSourceField` on a non-string property, or read as the resolved value
Declare a `DataSourceField` property as `z.string()`. On a non-string property the host stores a
structured data-source object instead of the chain string, and `resolveDataPath` only accepts a
string — it resolves to `null`. On a string property the stored value is the method chain the user
typed (e.g. `"current_account.id"`), not the account ID: pass `data.accountId` to `resolveDataPath`
rather than using it directly.
### [MEDIUM] A `ui:field` name the host doesn't register
A `ui:field` name the host has no component for falls back to RJSF's default field for the
property's type — no build or validation error, just a plain input instead of the intended control.
Spell `'DataSourceField'` exactly; module-supplied field components are not possible (see
"Everything is JSON" above).
### [HIGH] `key` uses the wrong charset or collides with another module
`key` must match `^[a-z0-9_]+$` and be unique within the app — it's embedded verbatim into a
persisted module identity string and DOM ids. A bad charset fails zod validation at build time
(`closeBundle`); a duplicate throws
`duplicate module key "..." — keys must be unique within an app`. Never change a released module's
`key` — it's written into saved layouts.
Source: `src/schema.ts` (`moduleKeySchema`), `src/vite-plugin.ts` (`assertUniqueKeys`).
### [HIGH] `defaultSize` missing an entry for a listed layout
Wrong:
```ts
defineLayoutModule({
layouts: ['case', 'account'],
defaultSize: { case: { width: 4, height: 20 } }, // no `account` entry
});
```
Correct:
```ts
defineLayoutModule({
layouts: ['case', 'account'],
defaultSize: {
case: { width: 4, height: 20 },
account: { width: 6, height: 24 },
},
});
```
The manifest schema requires an entry in `defaultSize` for every layout named in `layouts`; a
missing one fails validation in `closeBundle` with
`defaultSize must have an entry for every layout in \`layouts\``. This error surfaces at build time,
not at the defineLayoutModule call site.
This requirement applies to each listed layout; an empty `layouts` imposes no `defaultSize`
requirement, and the host falls back to its own default size.
Source: `src/schema.ts` (`remoteModuleRegistrationManifestV1Schema` `.refine`).
### [MEDIUM] `render.entry` is not an HTML document
The entry must be an `.html` document. Other extensions fail in `config()` before the plugin checks
whether the file exists:
`[persona-layout-modules] <key>: render "<entry>" must be an .html document — only document-delivered modules are supported`
### [MEDIUM] `render.entry` doesn't resolve to a real file
`personaLayoutModules` fails fast in `configResolved` — before running a full build — when an entry
path doesn't exist on disk: `render "..." does not resolve to a file (...)`. This is nearly always a
relative-path mistake: `entry` resolves against the Vite **root**, not against `vite.config.ts`'s
directory, so a non-default `root` in the same config changes what a given relative `entry` points
at.
Source: `src/vite-plugin.ts` (`checkDeliveryExists`).
### [MEDIUM] `manifestPath` set to a directory instead of a file
Use a file path such as `/manifests/my-app.json`, not a directory such as `/manifests/`.
`manifestPath` defaults to `DEFAULT_MANIFEST_PATH` (`/_layout_modules_manifest.json`); a leading `/`
is optional and stripped either way. Code outside `vite.config.ts` that needs the default (e.g. a
subapp server adding CORS on the manifest route) should import `DEFAULT_MANIFEST_PATH` from the
runtime-safe `.` entry to avoid pulling in Vite.
Source: `src/vite-plugin.ts` (`PersonaLayoutModulesOptions.manifestPath` doc comment),
`src/manifest-path.ts`.