@persona/layout-editor-remote-module 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,12 +71,24 @@ import { RemoteModuleFrame } from '@persona/layout-editor-remote-module/bridge-h
71
71
  data={moduleData}
72
72
  context={moduleContext}
73
73
  dynamicHeight
74
+ loadingFallback={<div>Loading module…</div>}
74
75
  resolveDataPath={resolveDataPath}
75
76
  />;
76
77
  ```
77
78
 
78
79
  The required `sandbox` value is chosen by the host.
79
80
 
81
+ `loadingFallback` is drawn over the iframe until the module has rendered. `LayoutModuleBridge`
82
+ advertises the `loaded` message automatically after its first render and waits for the first height
83
+ measurement before sending it when height observation is enabled. A module that needs a loading
84
+ state should render it on its first render; `LayoutModuleBridge`'s `fallback` is hidden under the
85
+ host overlay until then and remains available for render errors.
86
+
87
+ - `createModuleBridge` and `useRemoteModuleBridge` keep legacy behavior by default; direct bridge
88
+ users opt in with `signalsLoaded: true` and call `bridge.signalLoaded()` after rendering.
89
+ - Frames without the `loaded` capability are treated as legacy: `ready`, another bridge message, or
90
+ a silent-frame timeout after iframe load dismisses the overlay.
91
+
80
92
  By default, the bridge accepts host origins matching `*.withpersona.com`,
81
93
  `*.withpersona-staging.com`, `localhost`, and `127.0.0.1`. Add development or preview origins with
82
94
  `allowedHostOrigins`, optionally composing the exported `personaDashboardHostOrigins` helper:
@@ -168,3 +180,24 @@ const { modules } = parseRemoteModuleManifest(await response.json());
168
180
  It throws `RemoteModuleManifestParseError` (with `.issues` and a prettified `.message`) on a
169
181
  malformed document; `safeParseRemoteModuleManifest` returns `{ success, data | error }` instead of
170
182
  throwing.
183
+
184
+ ### Rendering a config panel
185
+
186
+ A host stores a placed module's config under a `config` property, so its RJSF form edits
187
+ `{ config }`. The `./config-form` entry adapts a manifest to that shape, with no React or RJSF
188
+ dependency:
189
+
190
+ ```ts
191
+ import {
192
+ nestRemoteConfigSchema,
193
+ nestRemoteUiConfigSchema,
194
+ remoteConfigDefaults,
195
+ } from '@persona/layout-editor-remote-module/config-form';
196
+
197
+ const schema = nestRemoteConfigSchema(module.configSchema); // `$ref`s rewritten to match
198
+ const uiSchema = nestRemoteUiConfigSchema(module.uiConfigSchema);
199
+ const initialConfig = remoteConfigDefaults(module.configSchema); // top-level `default`s
200
+ ```
201
+
202
+ Render the form with `schema`, `uiSchema` and `formData: { config }`, and read the edited config
203
+ back from `formData.config`.
@@ -1,4 +1,5 @@
1
- import { n as Json, t as RemoteModuleContext } from "./protocol-Dqnl8JTs.mjs";
1
+ import { t as Json } from "./types-CBSiNsDJ.mjs";
2
+ import { t as RemoteModuleContext } from "./protocol-ClWDYwW2.mjs";
2
3
  import { ReactNode } from "react";
3
4
 
4
5
  //#region src/bridge/origin.d.ts
@@ -12,6 +13,7 @@ type ModuleBridgeInitPayload = {
12
13
  type CreateModuleBridgeOptions = {
13
14
  allowedHostOrigins?: (string | RegExp)[] | ((origin: string) => boolean);
14
15
  resolveTimeoutMs?: number;
16
+ signalsLoaded?: boolean;
15
17
  };
16
18
  type ModuleBridge = {
17
19
  ready: () => Promise<ModuleBridgeInitPayload>;
@@ -21,6 +23,7 @@ type ModuleBridge = {
21
23
  reportRenderError: (message: string) => void;
22
24
  resolveDataPath: (dataPath: string, variableMap?: Record<string, string | number>) => Promise<Json>;
23
25
  observeHeight: (element: Element) => () => void;
26
+ signalLoaded: () => void;
24
27
  destroy: () => void;
25
28
  };
26
29
  //#endregion
@@ -1,4 +1,4 @@
1
- import { c as resolveAllowedHostOrigins, n as SOURCE, o as candidateParentOrigin, r as parseHostToModuleMessage, s as personaDashboardHostOrigins, t as PROTOCOL_VERSION } from "./protocol-D_pdVZjs.mjs";
1
+ import { c as resolveAllowedHostOrigins, n as SOURCE, o as candidateParentOrigin, r as parseHostToModuleMessage, s as personaDashboardHostOrigins, t as PROTOCOL_VERSION } from "./protocol-BfjTaW1S.mjs";
2
2
  import { Component, useEffect, useRef, useState } from "react";
3
3
  import { jsx } from "react/jsx-runtime";
4
4
 
@@ -70,7 +70,7 @@ const measureDocumentHeight = (element) => {
70
70
  }
71
71
  return Math.ceil(rect.top + window.scrollY + rect.height + trailing);
72
72
  };
73
- function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins, resolveTimeoutMs = 3e4 } = {}) {
73
+ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins, resolveTimeoutMs = 3e4, signalsLoaded = false } = {}) {
74
74
  const isEmbedded = typeof window !== "undefined" && window.parent !== window;
75
75
  const allowedOrigin = resolveAllowedHostOrigins(allowedHostOrigins);
76
76
  if (!isEmbedded) {
@@ -88,6 +88,7 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
88
88
  throw notEmbeddedError;
89
89
  },
90
90
  observeHeight: () => () => {},
91
+ signalLoaded: () => {},
91
92
  destroy: () => {}
92
93
  };
93
94
  }
@@ -96,8 +97,12 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
96
97
  let data = null;
97
98
  let context = null;
98
99
  let readyResolved = false;
100
+ let initialReadyPosted = false;
101
+ let loadedRequested = false;
102
+ let loadedPosted = false;
99
103
  const listeners = /* @__PURE__ */ new Set();
100
104
  const observers = /* @__PURE__ */ new Set();
105
+ const pendingHeightObservers = /* @__PURE__ */ new Set();
101
106
  const pendingRequests = createPendingRequests();
102
107
  const queuedRequests = /* @__PURE__ */ new Map();
103
108
  let resolveReady;
@@ -107,7 +112,18 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
107
112
  rejectReady = reject;
108
113
  });
109
114
  const post = (message) => {
110
- if (hostOrigin) window.parent.postMessage(message, hostOrigin);
115
+ if (!destroyed && hostOrigin) {
116
+ window.parent.postMessage(message, hostOrigin);
117
+ return true;
118
+ }
119
+ return false;
120
+ };
121
+ const flushLoaded = () => {
122
+ if (!destroyed && loadedRequested && !loadedPosted && hostOrigin && pendingHeightObservers.size === 0) loadedPosted = post({
123
+ source: SOURCE,
124
+ protocolVersion: PROTOCOL_VERSION,
125
+ type: "loaded"
126
+ });
111
127
  };
112
128
  const flushQueuedRequests = () => {
113
129
  if (!hostOrigin) return;
@@ -127,7 +143,6 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
127
143
  if (!allowedOrigin(origin)) return false;
128
144
  if (hostOrigin !== void 0 && hostOrigin !== origin) return false;
129
145
  hostOrigin = origin;
130
- flushQueuedRequests();
131
146
  return true;
132
147
  };
133
148
  const notify = () => {
@@ -137,6 +152,17 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
137
152
  if (destroyed || event.source !== window.parent) return;
138
153
  const message = parseHostToModuleMessage(event.data);
139
154
  if (!message || !pinHostOrigin(event.origin)) return;
155
+ if (message.type === "init" && signalsLoaded && !initialReadyPosted) {
156
+ initialReadyPosted = true;
157
+ post({
158
+ source: SOURCE,
159
+ protocolVersion: PROTOCOL_VERSION,
160
+ type: "ready",
161
+ capabilities: { loaded: true }
162
+ });
163
+ }
164
+ flushQueuedRequests();
165
+ flushLoaded();
140
166
  switch (message.type) {
141
167
  case "init":
142
168
  data = message.data;
@@ -166,11 +192,15 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
166
192
  };
167
193
  window.addEventListener("message", onMessage);
168
194
  const parentOrigin = candidateParentOrigin();
169
- if (parentOrigin && allowedOrigin(parentOrigin)) window.parent.postMessage({
170
- source: SOURCE,
171
- protocolVersion: PROTOCOL_VERSION,
172
- type: "ready"
173
- }, parentOrigin);
195
+ if (parentOrigin && allowedOrigin(parentOrigin)) {
196
+ window.parent.postMessage({
197
+ source: SOURCE,
198
+ protocolVersion: PROTOCOL_VERSION,
199
+ type: "ready",
200
+ ...signalsLoaded ? { capabilities: { loaded: true } } : {}
201
+ }, parentOrigin);
202
+ initialReadyPosted = true;
203
+ }
174
204
  return {
175
205
  ready: () => readyPromise,
176
206
  getData: () => data,
@@ -202,17 +232,19 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
202
232
  let lastHeight;
203
233
  let frame;
204
234
  let disconnected = false;
235
+ let observer;
205
236
  const postHeight = (height) => {
206
237
  if (disconnected || height === lastHeight) return;
207
- lastHeight = height;
208
- post({
238
+ if (!post({
209
239
  source: SOURCE,
210
240
  protocolVersion: PROTOCOL_VERSION,
211
241
  type: "set-height",
212
242
  height
213
- });
243
+ })) return;
244
+ lastHeight = height;
245
+ if (pendingHeightObservers.delete(observer)) flushLoaded();
214
246
  };
215
- const observer = new ResizeObserver(() => {
247
+ observer = new ResizeObserver(() => {
216
248
  if (frame !== void 0) return;
217
249
  const callback = () => {
218
250
  frame = void 0;
@@ -221,6 +253,7 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
221
253
  frame = typeof window.requestAnimationFrame === "function" ? window.requestAnimationFrame(callback) : window.setTimeout(callback, 0);
222
254
  });
223
255
  observers.add(observer);
256
+ pendingHeightObservers.add(observer);
224
257
  observer.observe(element);
225
258
  const body = element.ownerDocument?.body;
226
259
  if (body && body !== element) observer.observe(body);
@@ -232,14 +265,21 @@ function createModuleBridge({ allowedHostOrigins = personaDashboardHostOrigins,
232
265
  }
233
266
  observer.disconnect();
234
267
  observers.delete(observer);
268
+ if (pendingHeightObservers.delete(observer)) flushLoaded();
235
269
  };
236
270
  },
271
+ signalLoaded() {
272
+ if (destroyed || loadedRequested) return;
273
+ loadedRequested = true;
274
+ flushLoaded();
275
+ },
237
276
  destroy() {
238
277
  if (destroyed) return;
239
278
  destroyed = true;
240
279
  window.removeEventListener("message", onMessage);
241
280
  for (const observer of observers) observer.disconnect();
242
281
  observers.clear();
282
+ pendingHeightObservers.clear();
243
283
  queuedRequests.clear();
244
284
  const error = new BridgeDestroyedError();
245
285
  pendingRequests.rejectAll(error);
@@ -320,7 +360,8 @@ var BridgeErrorBoundary = class extends Component {
320
360
  function LayoutModuleBridge({ allowedHostOrigins, resolveTimeoutMs, observeHeight = true, fallback = null, children }) {
321
361
  const { bridge, context, data, ready, resolveDataPath } = useRemoteModuleBridge({
322
362
  allowedHostOrigins,
323
- resolveTimeoutMs
363
+ resolveTimeoutMs,
364
+ signalsLoaded: true
324
365
  });
325
366
  const rootRef = useRef(null);
326
367
  useEffect(() => {
@@ -338,6 +379,9 @@ function LayoutModuleBridge({ allowedHostOrigins, resolveTimeoutMs, observeHeigh
338
379
  observeHeight,
339
380
  ready
340
381
  ]);
382
+ useEffect(() => {
383
+ if (ready && bridge) bridge.signalLoaded();
384
+ }, [bridge, ready]);
341
385
  if (!ready || !bridge) return fallback;
342
386
  return /* @__PURE__ */ jsx(BridgeErrorBoundary, {
343
387
  bridge,
@@ -1,4 +1,5 @@
1
- import { n as Json, t as RemoteModuleContext } from "./protocol-Dqnl8JTs.mjs";
1
+ import { t as Json } from "./types-CBSiNsDJ.mjs";
2
+ import { t as RemoteModuleContext } from "./protocol-ClWDYwW2.mjs";
2
3
  import { CSSProperties, ReactNode, Ref } from "react";
3
4
 
4
5
  //#region src/bridge/host/buildModuleSrc.d.ts
@@ -36,6 +37,8 @@ type RemoteModuleFrameProps = {
36
37
  context: RemoteModuleContext;
37
38
  resolveDataPath: ResolveDataPath;
38
39
  onRenderError?: (message: string) => void;
40
+ /** Rendered over the iframe until the module reports that it has loaded. */
41
+ loadingFallback?: ReactNode;
39
42
  };
40
43
  declare function RemoteModuleFrame({
41
44
  documentUrl,
@@ -58,7 +61,8 @@ declare function RemoteModuleFrame({
58
61
  data,
59
62
  context,
60
63
  resolveDataPath,
61
- onRenderError
64
+ onRenderError,
65
+ loadingFallback
62
66
  }: RemoteModuleFrameProps): ReactNode;
63
67
  //#endregion
64
68
  export { type Json, type RemoteModuleContext, RemoteModuleFrame, type RemoteModuleFrameProps, type ResolveDataPath };
@@ -1,6 +1,6 @@
1
- import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-D_pdVZjs.mjs";
1
+ import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-BfjTaW1S.mjs";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { jsx } from "react/jsx-runtime";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
4
 
5
5
  //#region src/bridge/host/buildModuleSrc.ts
6
6
  const reservedParams = new Set([
@@ -24,11 +24,15 @@ function buildModuleSrc(documentUrl, options = {}) {
24
24
 
25
25
  //#endregion
26
26
  //#region src/bridge/host/createHostBridge.ts
27
- function createHostBridge({ iframe, moduleOrigin, getData, getContext, resolveDataPath, onHeight, onRenderError, initRetryMs = 250, initRetryTimeoutMs = 1e4 }) {
27
+ function createHostBridge({ iframe, moduleOrigin, getData, getContext, resolveDataPath, onHeight, onRenderError, onLoaded, initRetryMs = 250, initRetryTimeoutMs = 1e4 }) {
28
28
  assertNotSelfOrigin(moduleOrigin);
29
29
  if (moduleOrigin === "*") throw new Error("Remote module origin must be explicit");
30
30
  let destroyed = false;
31
31
  let initialized = false;
32
+ let capable;
33
+ let loaded = false;
34
+ let fallbackTimer;
35
+ let fallbackTimerStarted = false;
32
36
  let initRetryTimer;
33
37
  const clearInitRetry = () => {
34
38
  if (initRetryTimer !== void 0) {
@@ -36,6 +40,18 @@ function createHostBridge({ iframe, moduleOrigin, getData, getContext, resolveDa
36
40
  initRetryTimer = void 0;
37
41
  }
38
42
  };
43
+ const clearFallbackTimer = () => {
44
+ if (fallbackTimer !== void 0) {
45
+ clearTimeout(fallbackTimer);
46
+ fallbackTimer = void 0;
47
+ }
48
+ };
49
+ const markLoaded = () => {
50
+ if (loaded) return;
51
+ loaded = true;
52
+ clearFallbackTimer();
53
+ onLoaded?.();
54
+ };
39
55
  const post = (message) => {
40
56
  if (!destroyed) iframe.contentWindow?.postMessage(message, moduleOrigin);
41
57
  };
@@ -72,17 +88,38 @@ function createHostBridge({ iframe, moduleOrigin, getData, getContext, resolveDa
72
88
  });
73
89
  };
74
90
  const onLoad = () => {
75
- if (!destroyed) startInitRetry();
91
+ if (!destroyed) {
92
+ if (!fallbackTimerStarted) {
93
+ fallbackTimerStarted = true;
94
+ fallbackTimer = setTimeout(() => {
95
+ fallbackTimer = void 0;
96
+ if (capable === void 0) markLoaded();
97
+ }, Math.max(initRetryTimeoutMs, 0) + 500);
98
+ }
99
+ startInitRetry();
100
+ }
76
101
  };
77
102
  const onMessage = (event) => {
78
103
  if (destroyed || event.origin !== moduleOrigin || event.source !== iframe.contentWindow) return;
79
104
  const message = parseModuleToHostMessage(event.data);
80
105
  if (!message) return;
81
106
  clearInitRetry();
107
+ if (message.type === "ready") {
108
+ if (capable === void 0) {
109
+ capable = message.capabilities?.loaded === true;
110
+ if (!capable) markLoaded();
111
+ }
112
+ } else if (message.type !== "loaded" && message.type !== "render-error" && capable === void 0) {
113
+ capable = false;
114
+ markLoaded();
115
+ }
82
116
  switch (message.type) {
83
117
  case "ready":
84
118
  sendInit();
85
119
  break;
120
+ case "loaded":
121
+ markLoaded();
122
+ break;
86
123
  case "request-data":
87
124
  sendData(getData(), message.requestId);
88
125
  break;
@@ -90,6 +127,7 @@ function createHostBridge({ iframe, moduleOrigin, getData, getContext, resolveDa
90
127
  onHeight?.(message.height);
91
128
  break;
92
129
  case "render-error":
130
+ markLoaded();
93
131
  onRenderError?.(message.message);
94
132
  break;
95
133
  case "resolve-data-path":
@@ -131,6 +169,7 @@ function createHostBridge({ iframe, moduleOrigin, getData, getContext, resolveDa
131
169
  if (destroyed) return;
132
170
  destroyed = true;
133
171
  clearInitRetry();
172
+ clearFallbackTimer();
134
173
  window.removeEventListener("message", onMessage);
135
174
  iframe.removeEventListener("load", onLoad);
136
175
  }
@@ -145,7 +184,7 @@ function withDynamicHeight(context, dynamicHeight) {
145
184
  dynamicHeight
146
185
  };
147
186
  }
148
- function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environmentId, locale, extraParams, title, sandbox, referrerPolicy = "no-referrer", initRetryMs, initRetryTimeoutMs, dynamicHeight = false, heightOffset = 0, minHeight, className, style, iframeRef: externalIframeRef, data, context, resolveDataPath, onRenderError }) {
187
+ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environmentId, locale, extraParams, title, sandbox, referrerPolicy = "no-referrer", initRetryMs, initRetryTimeoutMs, dynamicHeight = false, heightOffset = 0, minHeight, className, style, iframeRef: externalIframeRef, data, context, resolveDataPath, onRenderError, loadingFallback }) {
149
188
  const iframeRef = useRef(null);
150
189
  const dataRef = useRef(data);
151
190
  const contextRef = useRef(withDynamicHeight(context, dynamicHeight));
@@ -154,6 +193,7 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
154
193
  const bridgeRef = useRef(void 0);
155
194
  const externalCleanupRef = useRef(void 0);
156
195
  const [height, setHeight] = useState();
196
+ const [loadedSrc, setLoadedSrc] = useState();
157
197
  const setRefs = useCallback((node) => {
158
198
  iframeRef.current = node;
159
199
  if (typeof externalIframeRef === "function") if (node) {
@@ -184,10 +224,10 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
184
224
  locale
185
225
  ]);
186
226
  useEffect(() => {
187
- const iframe = iframeRef.current;
188
- if (!iframe) return;
227
+ const iframe$1 = iframeRef.current;
228
+ if (!iframe$1) return;
189
229
  const bridge = createHostBridge({
190
- iframe,
230
+ iframe: iframe$1,
191
231
  moduleOrigin,
192
232
  getData: () => dataRef.current,
193
233
  getContext: () => contextRef.current,
@@ -197,7 +237,8 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
197
237
  onHeight: (nextHeight) => {
198
238
  setHeight(nextHeight);
199
239
  },
200
- onRenderError: (message) => onRenderErrorRef.current?.(message)
240
+ onRenderError: (message) => onRenderErrorRef.current?.(message),
241
+ onLoaded: () => setLoadedSrc(src)
201
242
  });
202
243
  bridgeRef.current = bridge;
203
244
  return () => {
@@ -217,7 +258,7 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
217
258
  bridgeRef.current?.pushContext(hostContext);
218
259
  }, [hostContext]);
219
260
  const computedHeight = dynamicHeight && height !== void 0 ? `${Math.max(height + heightOffset, minHeight ?? 0)}px` : minHeight === void 0 ? "100%" : `${minHeight}px`;
220
- return /* @__PURE__ */ jsx("iframe", {
261
+ const iframe = /* @__PURE__ */ jsx("iframe", {
221
262
  ref: setRefs,
222
263
  src,
223
264
  title,
@@ -232,6 +273,20 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
232
273
  border: "none"
233
274
  }
234
275
  }, src);
276
+ return /* @__PURE__ */ jsxs("div", {
277
+ style: {
278
+ position: "relative",
279
+ width: "100%",
280
+ height: computedHeight === "100%" ? "100%" : void 0
281
+ },
282
+ children: [iframe, loadingFallback !== void 0 && !(loadedSrc === src) && /* @__PURE__ */ jsx("div", {
283
+ style: {
284
+ position: "absolute",
285
+ inset: 0
286
+ },
287
+ children: loadingFallback
288
+ })]
289
+ });
235
290
  }
236
291
 
237
292
  //#endregion
@@ -0,0 +1,23 @@
1
+ import { n as JsonSchemaObject, t as Json } from "./types-CBSiNsDJ.mjs";
2
+
3
+ //#region src/config-form.d.ts
4
+
5
+ /** JSON pointer, in the nested schema, of the module's own config schema. */
6
+ declare const REMOTE_CONFIG_POINTER_PREFIX = "#/properties/config";
7
+ /**
8
+ * Nest a manifest `configSchema` under a top-level `config` property, rewriting `$ref`s so they
9
+ * still resolve. Drops `$schema` and `$id`; keeps `$defs` / `definitions` at the root.
10
+ */
11
+ declare function nestRemoteConfigSchema(configSchema: JsonSchemaObject): JsonSchemaObject;
12
+ /**
13
+ * Nest a manifest `uiConfigSchema` to match {@link nestRemoteConfigSchema}, hiding the wrapper
14
+ * object's title so the module's fields render as if top level.
15
+ */
16
+ declare function nestRemoteUiConfigSchema(uiConfigSchema: JsonSchemaObject | undefined): JsonSchemaObject;
17
+ /**
18
+ * A config holding each top-level `configSchema` property's `default`. Properties without one are
19
+ * left unset; defaults are cloned so the result can be mutated freely.
20
+ */
21
+ declare function remoteConfigDefaults(configSchema: JsonSchemaObject | undefined): Record<string, Json>;
22
+ //#endregion
23
+ export { REMOTE_CONFIG_POINTER_PREFIX, nestRemoteConfigSchema, nestRemoteUiConfigSchema, remoteConfigDefaults };
@@ -0,0 +1,79 @@
1
+ //#region src/config-form.ts
2
+ /** JSON pointer, in the nested schema, of the module's own config schema. */
3
+ const REMOTE_CONFIG_POINTER_PREFIX = "#/properties/config";
4
+ /** Keywords whose values are instance data, never schemas, so a `$ref` key inside them is data. */
5
+ const LITERAL_KEYWORDS = new Set([
6
+ "const",
7
+ "default",
8
+ "enum",
9
+ "examples"
10
+ ]);
11
+ /** Keywords whose values map arbitrary names to schemas, so a name like `default` is a schema. */
12
+ const SCHEMA_MAP_KEYWORDS = new Set([
13
+ "$defs",
14
+ "definitions",
15
+ "dependentSchemas",
16
+ "patternProperties",
17
+ "properties"
18
+ ]);
19
+ function rewriteSchemaMap(value, schemaId) {
20
+ if (value == null || typeof value !== "object" || Array.isArray(value)) return value;
21
+ return Object.fromEntries(Object.entries(value).map(([name, schema]) => [name, rewriteReferences(schema, schemaId)]));
22
+ }
23
+ function rewriteReferences(value, schemaId) {
24
+ if (Array.isArray(value)) return value.map((item) => rewriteReferences(item, schemaId));
25
+ if (value == null || typeof value !== "object") return value;
26
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => {
27
+ if (key === "$ref" && typeof child === "string") return [key, rewriteReference(child, schemaId)];
28
+ if (LITERAL_KEYWORDS.has(key)) return [key, child];
29
+ if (SCHEMA_MAP_KEYWORDS.has(key)) return [key, rewriteSchemaMap(child, schemaId)];
30
+ return [key, rewriteReferences(child, schemaId)];
31
+ }));
32
+ }
33
+ function rewriteReference(reference, schemaId) {
34
+ if (schemaId != null) {
35
+ if (reference === schemaId) return REMOTE_CONFIG_POINTER_PREFIX;
36
+ if (reference.startsWith(`${schemaId}#`)) return rewriteReference(reference.slice(schemaId.length));
37
+ }
38
+ if (reference === "#") return REMOTE_CONFIG_POINTER_PREFIX;
39
+ if (reference.startsWith("#/$defs/") || reference.startsWith("#/definitions/")) return reference;
40
+ if (reference.startsWith("#/")) return `${REMOTE_CONFIG_POINTER_PREFIX}${reference.slice(1)}`;
41
+ return reference;
42
+ }
43
+ /**
44
+ * Nest a manifest `configSchema` under a top-level `config` property, rewriting `$ref`s so they
45
+ * still resolve. Drops `$schema` and `$id`; keeps `$defs` / `definitions` at the root.
46
+ */
47
+ function nestRemoteConfigSchema(configSchema) {
48
+ const { $defs, definitions, $id, ...rest } = configSchema;
49
+ const body = Object.fromEntries(Object.entries(rest).filter(([key]) => key !== "$schema"));
50
+ const schemaId = typeof $id === "string" ? $id : void 0;
51
+ return {
52
+ type: "object",
53
+ properties: { config: rewriteReferences(body, schemaId) },
54
+ ...$defs != null && { $defs: rewriteSchemaMap($defs, schemaId) },
55
+ ...definitions != null && { definitions: rewriteSchemaMap(definitions, schemaId) }
56
+ };
57
+ }
58
+ /**
59
+ * Nest a manifest `uiConfigSchema` to match {@link nestRemoteConfigSchema}, hiding the wrapper
60
+ * object's title so the module's fields render as if top level.
61
+ */
62
+ function nestRemoteUiConfigSchema(uiConfigSchema) {
63
+ return { config: {
64
+ ...uiConfigSchema,
65
+ "ui:title": ""
66
+ } };
67
+ }
68
+ /**
69
+ * A config holding each top-level `configSchema` property's `default`. Properties without one are
70
+ * left unset; defaults are cloned so the result can be mutated freely.
71
+ */
72
+ function remoteConfigDefaults(configSchema) {
73
+ const properties = configSchema?.properties;
74
+ if (properties == null || typeof properties !== "object" || Array.isArray(properties)) return {};
75
+ return Object.fromEntries(Object.entries(properties).filter(([, propertySchema]) => typeof propertySchema === "object" && propertySchema != null && !Array.isArray(propertySchema) && "default" in propertySchema).map(([key, propertySchema]) => [key, structuredClone(propertySchema.default)]));
76
+ }
77
+
78
+ //#endregion
79
+ export { REMOTE_CONFIG_POINTER_PREFIX, nestRemoteConfigSchema, nestRemoteUiConfigSchema, remoteConfigDefaults };
@@ -117,7 +117,12 @@ const requestIdSchema = z.string().min(1);
117
117
  const variableMapSchema = z.record(z.string(), z.union([z.string(), z.number()]));
118
118
  const readySchema = z.object({
119
119
  ...envelopeSchema,
120
- type: z.literal("ready")
120
+ type: z.literal("ready"),
121
+ capabilities: z.object({ loaded: z.boolean().optional() }).optional()
122
+ });
123
+ const loadedSchema = z.object({
124
+ ...envelopeSchema,
125
+ type: z.literal("loaded")
121
126
  });
122
127
  const requestDataSchema = z.object({
123
128
  ...envelopeSchema,
@@ -161,6 +166,7 @@ const remoteModuleContextSchema = z.object({
161
166
  });
162
167
  const moduleToHostMessageSchema = z.discriminatedUnion("type", [
163
168
  readySchema,
169
+ loadedSchema,
164
170
  requestDataSchema,
165
171
  setHeightSchema,
166
172
  renderErrorSchema,
@@ -1,14 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
- //#region src/types.d.ts
4
-
5
- type Json = boolean | JsonObject | JsonValue[] | null | number | string;
6
- type JsonObject = {
7
- [key: string]: Json;
8
- };
9
- type JsonValue = Json;
10
- //#endregion
11
3
  //#region src/bridge/protocol.d.ts
4
+
12
5
  /**
13
6
  * Runtime facts about where the module is rendered, owned by the host and not
14
7
  * derivable by the module itself. Distinct from `data`, which is the module's
@@ -24,4 +17,4 @@ declare const remoteModuleContextSchema: z.ZodObject<{
24
17
  }, z.core.$strip>;
25
18
  type RemoteModuleContext = z.infer<typeof remoteModuleContextSchema>;
26
19
  //#endregion
27
- export { Json as n, RemoteModuleContext as t };
20
+ export { RemoteModuleContext as t };
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+
3
+ //#region src/types.d.ts
4
+
5
+ type Json = boolean | JsonObject | JsonValue[] | null | number | string;
6
+ /**
7
+ * Loose JSON Schema object. Deliberately not a full JSON Schema type system —
8
+ * this package only needs to carry what `z.toJSONSchema()` emits for consumers
9
+ * to validate or render.
10
+ */
11
+ type JsonSchemaObject = Record<string, unknown>;
12
+ type JsonObject = {
13
+ [key: string]: Json;
14
+ };
15
+ type JsonValue = Json;
16
+ //#endregion
17
+ export { JsonSchemaObject as n, Json as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@persona/layout-editor-remote-module",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Shared manifest format, Vite plugin, and host/iframe bridge for remotely-defined dashboard layout modules in Persona dashboard apps.",
5
5
  "license": "SEE LICENSE",
6
6
  "type": "module",
@@ -15,6 +15,9 @@
15
15
  ],
16
16
  "bridge-frame": [
17
17
  "dist/bridge-frame.d.mts"
18
+ ],
19
+ "config-form": [
20
+ "dist/config-form.d.mts"
18
21
  ]
19
22
  }
20
23
  },
@@ -111,6 +114,10 @@
111
114
  "types": "./dist/bridge-host.d.mts",
112
115
  "import": "./dist/bridge-host.mjs"
113
116
  },
117
+ "./config-form": {
118
+ "types": "./dist/config-form.d.mts",
119
+ "import": "./dist/config-form.mjs"
120
+ },
114
121
  "./bridge-frame": {
115
122
  "types": "./dist/bridge-frame.d.mts",
116
123
  "import": "./dist/bridge-frame.mjs"
@@ -6,11 +6,11 @@ description: >
6
6
  document entry, add a zod configSchema and an RJSF uiConfigSchema (labels, field order,
7
7
  DataSourceField pickers), and talk to the host from the iframe with LayoutModuleBridge (layout
8
8
  data, host context such as layoutType / layoutObjectId / isReadOnly / dynamicHeight,
9
- resolveDataPath, height). Activate when adding a layout module, integrating this package for the
10
- first time, wiring a module document to the dashboard host, or debugging a manifest that fails to
11
- build or validate (duplicate key, missing defaultSize entry, render path not found), a module that
12
- never receives data from the host, or an iframe whose height collapses or keeps growing under
13
- dynamic height.
9
+ resolveDataPath, height, loaded signal). Activate when adding a layout module, integrating this
10
+ package for the first time, wiring a module document to the dashboard host, or debugging a
11
+ manifest that fails to build or validate (duplicate key, missing defaultSize entry, render path
12
+ not found), a module that never receives data from the host, a host loading card that never
13
+ clears, or an iframe whose height collapses or keeps growing under dynamic height.
14
14
  metadata:
15
15
  type: core
16
16
  library: '@persona/layout-editor-remote-module'
@@ -266,7 +266,7 @@ import { LayoutModuleBridge } from '@persona/layout-editor-remote-module/bridge-
266
266
 
267
267
  export function App() {
268
268
  return (
269
- <LayoutModuleBridge fallback={<Spinner />}>
269
+ <LayoutModuleBridge>
270
270
  {({ data, context, resolveDataPath }) => (
271
271
  <RiskSummary data={data} context={context} resolveDataPath={resolveDataPath} />
272
272
  )}
@@ -276,7 +276,9 @@ export function App() {
276
276
  ```
277
277
 
278
278
  - `children` is only invoked once the host has sent the initial layout data — it never sees
279
- `data: undefined`. Until then `fallback` (default `null`) is rendered.
279
+ `data: undefined`. Until then `fallback` (default `null`) is rendered, hidden under the host's
280
+ loading card. After the first render the bridge posts `loaded` and the host removes the card, so
281
+ render any data-loading state from `children`.
280
282
  - `data` is the module's persisted config as JSON, and re-renders whenever the host pushes an
281
283
  update.
282
284
  - `context` (`RemoteModuleContext`) is where the module is rendered — host-owned facts the module
@@ -291,7 +293,8 @@ export function App() {
291
293
  borders, so the host can size the iframe (`observeHeight={false}` to opt out). The host uses the
292
294
  reported height only when [`context.dynamicHeight`](#host-context) is true; otherwise, the iframe
293
295
  keeps its grid size.
294
- - A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown.
296
+ - A render error thrown inside `children` is caught, reported to the host, and `fallback` is shown
297
+ after the host loading overlay is dismissed.
295
298
  - Mount exactly one `LayoutModuleBridge` per document.
296
299
 
297
300
  Props: `fallback?: ReactNode`, `observeHeight?: boolean` (default `true`),
@@ -373,17 +376,18 @@ from there.
373
376
 
374
377
  Source: `src/bridge/iframe/LayoutModuleBridge.tsx`.
375
378
 
376
- ### [MEDIUM] Module renders forever in `fallback` when embedded
379
+ ### [MEDIUM] Host loading card stays visible forever when embedded
377
380
 
378
- `children` never running means the handshake didn't complete. Check, in order: the document is
379
- really cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects
380
- unknown parents — pass your own only for a non-Persona dev host); the frame's
381
+ The host removes its loading card once `LayoutModuleBridge` has rendered `children`, so a card that
382
+ never clears usually means the handshake didn't complete. Check, in order: the document is really
383
+ cross-origin-embedded by a Persona host (the default `allowedHostOrigins` predicate rejects unknown
384
+ parents — pass your own only for a non-Persona dev host); the frame's
381
385
  `@persona/layout-editor-remote-module` is 0.5.3 or later (a frame on 0.5.2 or older rejects an
382
386
  `init` whose context has keys it doesn't know, e.g. `dynamicHeight`, sent by hosts on 0.8.0+, so
383
387
  `children` never runs); and the host is rendering it through `RemoteModuleFrame` (bare `<iframe>`s
384
- never send `init`). Opening the document directly in a tab is _not_ embedded — `data` and `context`
385
- resolve to `null` and `children` renders, so a stuck fallback is always a host, origin, or frame
386
- version problem.
388
+ never send `init`). For a direct `createModuleBridge` integration, also confirm that a bridge
389
+ created with `signalsLoaded: true` calls `signalLoaded()`. Opening the document directly in a tab is
390
+ _not_ embedded — `data` and `context` resolve to `null` and `children` renders.
387
391
 
388
392
  Source: `src/bridge/iframe/createModuleBridge.ts`, `src/bridge/origin.ts`, `src/bridge/protocol.ts`.
389
393