@persona/layout-editor-remote-module
getting-started
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.
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
yarn add @persona/layout-editor-remote-modulevite (^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)
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), 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:
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:
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:
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:
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:
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:
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 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:
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.
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.
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:
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:
defineLayoutModule({
layouts: ['case', 'account'],
defaultSize: { case: { width: 4, height: 20 } }, // no `account` entry
});Correct:
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.