Skip to content

Frame protocols

Framed widgets run in a real document. That gives browser-enforced isolation and also clips portals, modals, and full-viewport effects to the iframe box. Web Fragments solves that with reframing. Mountly keeps the iframe and offers two opt-in protocols on the existing typed frame channel instead.

The frame asks the host to open UI in the top document. Prefer named slots the host maps to its own components. Raw html is refused unless the host supplies sanitizeHtml.

import { iframeFeature, bindFrameOverlay, type FrameOverlayEvents } from "mountly/iframe";
interface Events extends FrameOverlayEvents {
invoicePaid: { invoiceId: string };
}
const billing = iframeFeature<Events>({
moduleId: "billing",
src: "https://billing.acme.com/widget",
title: "Billing",
channel: {
validators: {/* app events + frameOverlayValidators if you want */},
connect: (channel) =>
bindFrameOverlay(channel, {
renderSlot: ({ slot, props, container }) => {
if (slot === "checkout-modal") {
container.textContent = `Checkout ${props.plan}`;
}
},
// sanitizeHtml: (html) => DOMPurify.sanitize(html),
}),
},
});
import { mountAsFrame, openHostOverlay, closeHostOverlay } from "mountly/iframe/child";
mountAsFrame(widget, {
channel: {
connect: (channel) => {
openHostOverlay(channel, {
id: "checkout",
slot: "checkout-modal",
props: { plan: "pro" },
});
// later:
// closeHostOverlay(channel, "checkout");
},
},
});

Shared-context widgets keep light-DOM portals. Overlay breakout is only for the framed path.

A framed feature never writes window.history. It emits history:navigate; the host calls router.navigate() (or replace). The host fans URL changes back as history:sync.

import { createFeatureRouter } from "mountly/router";
import { bindFrameHistoryToRouter, requestHostNavigation } from "mountly/iframe";
const router = createFeatureRouter({
container: outlet,
routes: [{ path: "/billing/*", feature: billing }],
});
router.start();
iframeFeature({
moduleId: "billing",
src: "https://billing.acme.com/widget",
title: "Billing",
channel: {
connect: (channel) => bindFrameHistoryToRouter(channel, router),
},
});

From the frame:

import { requestHostNavigation } from "mountly/iframe/child";
requestHostNavigation(channel, { url: "/billing/2" });
requestHostNavigation(channel, { url: "/billing/2", history: "replace" });

Event names: history:navigate, history:sync (see HISTORY_NAVIGATE / HISTORY_SYNC).

Show a static shell until the frame reports ready — not SSR hydration:

iframeFeature({
moduleId: "billing",
src: "https://billing.acme.com/widget",
title: "Billing",
placeholder: "Loading billing…",
// or: placeholderUrl: "https://cdn.example/billing/skeleton.html",
});

Also available as showPlaceholder / showPlaceholderFromUrl from mountly/placeholder.

  • Shared context remains the default.
  • No gateway required.
  • One createWidget build still runs light, shadow, or framed.
  • Isolation upgrades are a host/manifest change.

See also: When to frame.