@persona/layout-editor-remote-module 0.8.1 → 0.10.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:
@@ -12,6 +12,7 @@ type ModuleBridgeInitPayload = {
12
12
  type CreateModuleBridgeOptions = {
13
13
  allowedHostOrigins?: (string | RegExp)[] | ((origin: string) => boolean);
14
14
  resolveTimeoutMs?: number;
15
+ signalsLoaded?: boolean;
15
16
  };
16
17
  type ModuleBridge = {
17
18
  ready: () => Promise<ModuleBridgeInitPayload>;
@@ -21,6 +22,7 @@ type ModuleBridge = {
21
22
  reportRenderError: (message: string) => void;
22
23
  resolveDataPath: (dataPath: string, variableMap?: Record<string, string | number>) => Promise<Json>;
23
24
  observeHeight: (element: Element) => () => void;
25
+ signalLoaded: () => void;
24
26
  destroy: () => void;
25
27
  };
26
28
  //#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-Br3C6FM7.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,5 +1,5 @@
1
1
  import { n as Json, t as RemoteModuleContext } from "./protocol-Dqnl8JTs.mjs";
2
- import { CSSProperties, ReactNode } from "react";
2
+ import { CSSProperties, ReactNode, Ref } from "react";
3
3
 
4
4
  //#region src/bridge/host/buildModuleSrc.d.ts
5
5
  type BuildModuleSrcOptions = {
@@ -30,10 +30,14 @@ type RemoteModuleFrameProps = {
30
30
  minHeight?: number;
31
31
  className?: string;
32
32
  style?: CSSProperties;
33
+ /** Receives the underlying <iframe> so a host can attach extra postMessage bridges. */
34
+ iframeRef?: Ref<HTMLIFrameElement>;
33
35
  data: Json;
34
36
  context: RemoteModuleContext;
35
37
  resolveDataPath: ResolveDataPath;
36
38
  onRenderError?: (message: string) => void;
39
+ /** Rendered over the iframe until the module reports that it has loaded. */
40
+ loadingFallback?: ReactNode;
37
41
  };
38
42
  declare function RemoteModuleFrame({
39
43
  documentUrl,
@@ -52,10 +56,12 @@ declare function RemoteModuleFrame({
52
56
  minHeight,
53
57
  className,
54
58
  style,
59
+ iframeRef: externalIframeRef,
55
60
  data,
56
61
  context,
57
62
  resolveDataPath,
58
- onRenderError
63
+ onRenderError,
64
+ loadingFallback
59
65
  }: RemoteModuleFrameProps): ReactNode;
60
66
  //#endregion
61
67
  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";
2
- import { useEffect, useMemo, useRef, useState } from "react";
3
- import { jsx } from "react/jsx-runtime";
1
+ import { a as assertNotSelfOrigin, i as parseModuleToHostMessage, n as SOURCE, t as PROTOCOL_VERSION } from "./protocol-Br3C6FM7.mjs";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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,14 +184,28 @@ 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, 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));
152
191
  const onRenderErrorRef = useRef(onRenderError);
153
192
  const resolveDataPathRef = useRef(resolveDataPath);
154
193
  const bridgeRef = useRef(void 0);
194
+ const externalCleanupRef = useRef(void 0);
155
195
  const [height, setHeight] = useState();
196
+ const [loadedSrc, setLoadedSrc] = useState();
197
+ const setRefs = useCallback((node) => {
198
+ iframeRef.current = node;
199
+ if (typeof externalIframeRef === "function") if (node) {
200
+ const cleanup = externalIframeRef(node);
201
+ externalCleanupRef.current = typeof cleanup === "function" ? cleanup : void 0;
202
+ } else if (externalCleanupRef.current) {
203
+ const cleanup = externalCleanupRef.current;
204
+ externalCleanupRef.current = void 0;
205
+ cleanup();
206
+ } else externalIframeRef(null);
207
+ else if (externalIframeRef) externalIframeRef.current = node;
208
+ }, [externalIframeRef]);
156
209
  const hostContext = useMemo(() => withDynamicHeight(context, dynamicHeight), [context, dynamicHeight]);
157
210
  dataRef.current = data;
158
211
  contextRef.current = hostContext;
@@ -171,10 +224,10 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
171
224
  locale
172
225
  ]);
173
226
  useEffect(() => {
174
- const iframe = iframeRef.current;
175
- if (!iframe) return;
227
+ const iframe$1 = iframeRef.current;
228
+ if (!iframe$1) return;
176
229
  const bridge = createHostBridge({
177
- iframe,
230
+ iframe: iframe$1,
178
231
  moduleOrigin,
179
232
  getData: () => dataRef.current,
180
233
  getContext: () => contextRef.current,
@@ -184,7 +237,8 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
184
237
  onHeight: (nextHeight) => {
185
238
  setHeight(nextHeight);
186
239
  },
187
- onRenderError: (message) => onRenderErrorRef.current?.(message)
240
+ onRenderError: (message) => onRenderErrorRef.current?.(message),
241
+ onLoaded: () => setLoadedSrc(src)
188
242
  });
189
243
  bridgeRef.current = bridge;
190
244
  return () => {
@@ -204,8 +258,8 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
204
258
  bridgeRef.current?.pushContext(hostContext);
205
259
  }, [hostContext]);
206
260
  const computedHeight = dynamicHeight && height !== void 0 ? `${Math.max(height + heightOffset, minHeight ?? 0)}px` : minHeight === void 0 ? "100%" : `${minHeight}px`;
207
- return /* @__PURE__ */ jsx("iframe", {
208
- ref: iframeRef,
261
+ const iframe = /* @__PURE__ */ jsx("iframe", {
262
+ ref: setRefs,
209
263
  src,
210
264
  title,
211
265
  sandbox,
@@ -219,6 +273,20 @@ function RemoteModuleFrame({ documentUrl, moduleOrigin, accessToken, environment
219
273
  border: "none"
220
274
  }
221
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
+ });
222
290
  }
223
291
 
224
292
  //#endregion
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@persona/layout-editor-remote-module",
3
- "version": "0.8.1",
3
+ "version": "0.10.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",
@@ -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