Patterns · Technical infrastructure
EmbeddedAppFrame
Embed a page of another Rook app: sandboxed iframe, ready handshake, auto-height and focus handoff with origin checks.
An embedded settings page (ready, auto-height)
import { useEffect, useState } from 'react';
import { EmbeddedAppFrame } from '@rookcom/monarch-react/shell';
/**
* Docs only: a same-origin stand-in for an embedded app page, served from a
* blob: URL. It copies this page's styles and theme so it looks like a Rook
* page, then speaks the embed contract (ready + resize) to its parent.
*/
function useDemoPage(body: string, speak: boolean): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
const head = Array.from(document.head.querySelectorAll('style, link[rel="stylesheet"]'))
// Absolute hrefs: a blob: document cannot resolve this page's relative URLs.
.map((n) =>
n instanceof HTMLLinkElement ? `<link rel="stylesheet" href="${n.href}">` : n.outerHTML,
)
.join('');
const attrs = Array.from(document.documentElement.attributes)
.map((a) => `${a.name}="${a.value.replace(/"/g, '"')}"`)
.join(' ');
const script = speak
? `<script>
const o = location.origin, p = 'rook-app-settings';
const send = (m) => parent.postMessage(Object.assign({ protocol: p, v: 1 }, m), o);
send({ type: 'ready' });
const size = () => { const b = document.body, cs = getComputedStyle(b); send({ type: 'resize', height: Math.ceil(b.getBoundingClientRect().height + parseFloat(cs.marginTop) + parseFloat(cs.marginBottom)) }); };
new ResizeObserver(size).observe(document.body); size();
addEventListener('message', (e) => { if (e.origin === o && e.data && e.data.type === 'ping') send({ type: 'ready' }); });
</script>`
: '';
const html = `<!doctype html><html ${attrs}><head><meta charset="utf-8">${head}<style>body{margin:0;padding:var(--mn-space-5);background:var(--mn-color-bg-surface);color:var(--mn-color-text-default);font-family:var(--mn-font-sans);font-size:var(--mn-text-sm)}h2{margin:0 0 var(--mn-space-2);font-size:var(--mn-text-base)}p{margin:0 0 var(--mn-space-3);color:var(--mn-color-text-muted)}label{display:flex;gap:var(--mn-space-2);align-items:center;margin-top:var(--mn-space-2)}</style></head><body>${body}${script}</body></html>`;
const next = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
setUrl(next);
return () => URL.revokeObjectURL(next);
}, [body, speak]);
return url;
}
const PAGE = `<h2>Email notifications</h2>
<p>Choose which updates reach your inbox.</p>
<label><input type="checkbox" checked> New tickets assigned to me</label>
<label><input type="checkbox"> Daily summary</label>
<label><input type="checkbox" checked> Mentions</label>`;
function Ready() {
const src = useDemoPage(PAGE, true);
if (!src) return null;
return (
<div style={{ width: '100%', maxWidth: '36rem' }}>
<EmbeddedAppFrame
src={src}
title="Example Desk: Email notifications"
allowedOrigins={[window.location.origin]}
height={160}
openHref="#open-in-app"
/>
</div>
);
}
<Ready />Usage examples
A page that never answers
import { useEffect, useState } from 'react';
import { EmbeddedAppFrame } from '@rookcom/monarch-react/shell';
/**
* Docs only: a same-origin stand-in for an embedded app page, served from a
* blob: URL. It copies this page's styles and theme so it looks like a Rook
* page, then speaks the embed contract (ready + resize) to its parent.
*/
function useDemoPage(body: string, speak: boolean): string | null {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
const head = Array.from(document.head.querySelectorAll('style, link[rel="stylesheet"]'))
// Absolute hrefs: a blob: document cannot resolve this page's relative URLs.
.map((n) =>
n instanceof HTMLLinkElement ? `<link rel="stylesheet" href="${n.href}">` : n.outerHTML,
)
.join('');
const attrs = Array.from(document.documentElement.attributes)
.map((a) => `${a.name}="${a.value.replace(/"/g, '"')}"`)
.join(' ');
const script = speak
? `<script>
const o = location.origin, p = 'rook-app-settings';
const send = (m) => parent.postMessage(Object.assign({ protocol: p, v: 1 }, m), o);
send({ type: 'ready' });
const size = () => { const b = document.body, cs = getComputedStyle(b); send({ type: 'resize', height: Math.ceil(b.getBoundingClientRect().height + parseFloat(cs.marginTop) + parseFloat(cs.marginBottom)) }); };
new ResizeObserver(size).observe(document.body); size();
addEventListener('message', (e) => { if (e.origin === o && e.data && e.data.type === 'ping') send({ type: 'ready' }); });
</script>`
: '';
const html = `<!doctype html><html ${attrs}><head><meta charset="utf-8">${head}<style>body{margin:0;padding:var(--mn-space-5);background:var(--mn-color-bg-surface);color:var(--mn-color-text-default);font-family:var(--mn-font-sans);font-size:var(--mn-text-sm)}h2{margin:0 0 var(--mn-space-2);font-size:var(--mn-text-base)}p{margin:0 0 var(--mn-space-3);color:var(--mn-color-text-muted)}label{display:flex;gap:var(--mn-space-2);align-items:center;margin-top:var(--mn-space-2)}</style></head><body>${body}${script}</body></html>`;
const next = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
setUrl(next);
return () => URL.revokeObjectURL(next);
}, [body, speak]);
return url;
}
function Unresponsive() {
const src = useDemoPage('<h2>Not in embed mode</h2>', false);
if (!src) return null;
return (
<div style={{ width: '100%', maxWidth: '36rem' }}>
<EmbeddedAppFrame
src={src}
title="Example Desk: Billing"
allowedOrigins={[window.location.origin]}
readyTimeout={1}
height={220}
openHref="#open-in-app"
messages={{ open: 'Open in Example Desk' }}
/>
</div>
);
}
<Unresponsive />Purpose
Rook ID’s App Settings hub shows each app’s settings pages inside its own layout. EmbeddedAppFrame is the host side of that embed contract (v1), and useEmbedReady is the embedded side, so every app speaks the same protocol instead of hand-writing postMessage code.
Use when
- Showing a page that belongs to another Rook app inside this one (App Settings, Integrations).
- In the embedded app: call
useEmbedReadyin the layout of pages that may be framed.
Avoid when
- Embedding Rook Chat: use ChatDock, which speaks the
rook-chat-embedprotocol. - Third-party content you do not control: keep the default sandbox and add nothing to
allow, or link out instead.
Design principles
- Closed by default: a minimal sandbox (scripts, its own session, forms), no permissions, and messages accepted only from the frame’s own window at an allowed origin.
- Never silent: a page that does not say
readywithin 12 seconds of visible time shows an error with Try again and an open-in-app link instead of an endless spinner. - The frame fits its content (auto-height), so the host page scrolls once instead of a box inside a box.
- Focus can cross the boundary on purpose: into the page when it is ready, and back out when the page hands it back.
API reference
import { APP_EMBED_PROTOCOL, APP_EMBED_VERSION, DEFAULT_EMBED_SANDBOX, EmbeddedAppFrame, MAX_EMBED_HEIGHT, decodeAppEmbedChildMessage, decodeAppEmbedHostMessage, encodeAppEmbedMessage, useEmbedReady } from '@rookcom/monarch-react/shell';| Export | Kind | Rendering | Root class |
|---|---|---|---|
APP_EMBED_PROTOCOL | utility | server-safe | – |
APP_EMBED_VERSION | utility | server-safe | – |
DEFAULT_EMBED_SANDBOX | utility | client | – |
EmbeddedAppFrame | component | client | .mn-embedded-app-frame |
MAX_EMBED_HEIGHT | utility | server-safe | – |
decodeAppEmbedChildMessage | utility | server-safe | – |
decodeAppEmbedHostMessage | utility | server-safe | – |
encodeAppEmbedMessage | utility | server-safe | – |
useEmbedReady | hook | client | – |
Forwards its ref to the wrapper; iframeRef reaches the iframe.
Also exported: useEmbedReady (embedded side), APP_EMBED_PROTOCOL, APP_EMBED_VERSION, DEFAULT_EMBED_SANDBOX, encodeAppEmbedMessage, decodeAppEmbedChildMessage, decodeAppEmbedHostMessage.
Generated from the TypeScript declarations of packages/react/src/patterns/technical/embedded-app-frame.
Properties
allowedOrigins defaults to the origin of src. Entries that are not a bare origin (*, a path, null) are dropped and never match.
sandbox takes tokens, not a string; allow-top-navigation is not accepted.
readyTimeout={0} waits forever (use only when the page shows its own errors).
EmbeddedAppFrame
Embeds a page of another Rook app (App Settings embed contract v1): an iframe with a minimal sandbox, a ready handshake, auto-height and focus handoff over postMessage, and loading and error states with Try again. Only messages from the frame's own window at an allowed origin are read, and nothing is ever posted to `*`.
| Prop | Type | Description |
|---|---|---|
src required | string | The page to embed (absolute URL, or relative to this page). |
title required | string | The iframe's accessible name, e.g. "Rook Tickets: Email settings". Required. |
allow | readonly string[] | Permissions policy features, e.g. `['clipboard-write']`. Default none. |
allowedOrigins | readonly string[] | Exact origins whose messages are accepted. Default: the origin of `src` only. Entries that are not a bare origin (`*`, paths) are ignored. |
autoHeight | boolean | Follow the page's `resize` messages. Default true. |
className | string | |
focusOnReady | boolean | Move focus into the page once it is ready. Default false. |
height | string | number | Height before the first `resize` message, or the fixed height without `autoHeight` (a number of pixels or any CSS length). Default 480. |
id | string | |
iframeRef | Ref<HTMLIFrameElement> | The iframe element. |
loadingContent | ReactNode | Replaces the loading content. |
maxHeight | number | Largest auto height in pixels; the page scrolls inside the frame beyond it. |
messages | Partial<EmbeddedAppFrameMessages> | Interface text, for translation. |
minHeight | number | Smallest auto height in pixels. Default 120. |
onFocusExit | ((direction: EmbedFocusDirection) => void) | The page handed focus back (`focus-exit`). Default: focus moves to the control after (forward) or before (backward) the frame. |
onReady | (() => void) | |
onStatusChange | ((status: EmbeddedAppFrameStatus) => void) | |
openHref | string | Link offered with the error (the page in its own app). Opens in a new tab. |
protocol | string | Protocol name in every message. Default `rook-app-settings` (App Settings contract v1). |
readyTimeout | number | Give up after this much visible time without `ready`. Default 12000 ms; 0 waits forever. |
referrerPolicy | import("/home/runner/work/monarch/monarch/node_modules/@types/react/index").HTMLAttributeReferrerPolicy | Default `strict-origin-when-cross-origin`. |
sandbox | readonly EmbedSandboxToken[] | Sandbox tokens. Default `DEFAULT_EMBED_SANDBOX` (scripts, same-origin, forms). |
Variants
| Prop | Values | Description |
|---|---|---|
autoHeight | 'true''false' | Follow resize messages between minHeight and maxHeight, or keep the fixed height. |
protocol | 'rook-app-settings''custom' | The protocol name in every message, for sibling contracts. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Loading | data-status="loading" | A status with a spinner covers the frame until the page says ready. |
| Ready | data-status="ready" | The page is shown; onReady runs once. |
| Error | data-status="error" | No ready in time: an alert with Try again (reloads the frame) and the open-in-app link. The frame is hidden from assistive technology. |
Interaction behaviour
- On every load the host posts
ping, so a page that announcedreadybefore the host listened says it again. - Try again remounts the iframe and keeps focus on the loading status instead of losing it to the page.
- The countdown pauses while the tab is hidden, since background tabs throttle the embedded page.
- A
focus-exitmessage moves focus to the control after (forward) or before (backward) the frame, or callsonFocusExit.
Keyboard support
| Keys | Action |
|---|---|
| Tab | Moves into and through the embedded page, then on to the host page. |
| Enter / Space | Try again and the open link, in the error state. |
Keys inside the embedded page belong to that page. With useEmbedReady, the page can call exitFocus (for example on Escape) to hand focus back.
Accessibility
- Role
- iframe (document) in a wrapper; status while loading, alert on error
- Accessible name
titlenames the iframe, e.g. "Rook Tickets: Email settings".
- The loading overlay is a
status; the error is analertso it is announced when it replaces the spinner. - The open link says it opens in a new tab.
- Security:
event.originmust be inallowedOrigins(exact match) andevent.sourcemust be this frame’s window; each message is validated against its exact shape (readyhas exactly three keys,resizea finite height under 100000), and anything else is dropped. - Security: the host posts only to the origin of
src, and only when it is allowed; nothing is posted to*.useEmbedReadyposts only to an allowed parent origin (fromancestorOriginsor the referrer) and reads messages only fromwindow.parentat that origin. - Threat model: a hostile page in another frame or tab cannot spoof
readyor resize the frame (origin and source checks); a hijacked embedded page cannot navigate the host (no top navigation), open popups, download files or use camera, microphone or clipboard unless the host opts in.allow-scriptstogether withallow-same-originis only safe for cross-origin pages: never point the frame at a page on the host’s own origin with untrusted content.
Responsive behaviour
- The frame is as wide as its container; its height follows the page within
minHeightandmaxHeight. - Loading and error content is centred and wraps at 320px.
Theme support
- Surface, border and text tokens for the wrapper and overlays. The embedded page draws itself; Rook apps share the theme cookie, so both sides match.
- The height transition is removed under reduced motion.
Do and don't
Do
- List exact origins in
allowedOrigins, from configuration, per environment. - Announce
readyonly when the embedded page can actually be used (signed in, workspace resolved, or the no-access screen). - Give every frame a specific
title.
Don't
- Do not add
allow-top-navigation,allow-popupsor permissions unless the embedded page needs them. - Do not post to
*or accept*as an origin, on either side. - Do not put secrets or tokens in the frame URL.
Related components
- ChatDock: The Rook Chat frame, with its own protocol.
- SupportPanel: Support chat beside the rail.
- NoAppAccess: What the embedded page shows (and announces ready for) without access.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | react |
| Monarch components | Button, Spinner, VisuallyHidden |
Testing coverage
- Titled iframe with the default sandbox, referrer policy and no permissions; loading status.
- Ready from the frame’s window at an allowed origin; onReady once.
- Drops other origins, look-alike origins, other windows, extra keys, other versions and protocols.
*and paths in allowedOrigins never match.- Resize within min and max; fixed height without autoHeight.
- Timeout error with Try again (new frame, focus kept) and the new-tab open link.
- Ping on load to the page’s own origin; focus on ready; focus-exit forward, backward and onFocusExit.
- Unknown sandbox tokens and bad permission names dropped; readyTimeout 0 waits.
- Protocol encode/decode both ways; useEmbedReady: inert when not framed, ready to an allowed parent only, ping answered, exitFocus.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/embedded-app-frame; Cross-browser axe: /components/embedded-app-frame |
Tests: packages/react/src/patterns/technical/embedded-app-frame