Source: https://monarchui.dev/components/embedded-app-frame/

Patterns · Technical infrastructure

# EmbeddedAppFrame

Embed a page of another Rook app: sandboxed iframe, ready handshake, auto-height and focus handoff with origin checks.

@rookcom/monarch-react/shell

Pattern

## An embedded settings page (ready, auto-height)

## Usage examples

### A page that never answers

## 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 `useEmbedReady` in the layout of pages that may be framed.

### Avoid when

- Embedding Rook Chat: use ChatDock, which speaks the `rook-chat-embed` protocol.
- 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 `ready` within 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

```tsx
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 announced `ready` before 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-exit` message moves focus to the control after (forward) or before (backward) the frame, or calls `onFocusExit`.

## 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

title

names the iframe, e.g. "Rook Tickets: Email settings".

- The loading overlay is a `status`; the error is an `alert` so it is announced when it replaces the spinner.
- The open link says it opens in a new tab.
- Security: `event.origin` must be in `allowedOrigins` (exact match) and `event.source` must be this frame’s window; each message is validated against its exact shape (`ready` has exactly three keys, `resize` a 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 `*`. `useEmbedReady` posts only to an allowed parent origin (from `ancestorOrigins` or the referrer) and reads messages only from `window.parent` at that origin.
- Threat model: a hostile page in another frame or tab cannot spoof `ready` or 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-scripts` together with `allow-same-origin` is 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 `minHeight` and `maxHeight`.
- 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 `ready` only 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-popups` or 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](https://monarchui.dev/components/chat-dock/): The Rook Chat frame, with its own protocol.
- [SupportPanel](https://monarchui.dev/components/support-panel/): Support chat beside the rail.
- [NoAppAccess](https://monarchui.dev/components/no-app-access/): What the embedded page shows (and announces ready for) without access.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [Spinner](https://monarchui.dev/components/spinner/), [VisuallyHidden](https://monarchui.dev/components/visually-hidden/) |

## 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`

## All example code

### An embedded settings page (ready, auto-height)

```tsx
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, '&quot;')}"`)
      .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 />
```

### A page that never answers

```tsx
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, '&quot;')}"`)
      .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 />
```
