Syncing Embedded Map State with the Host Page URL

Part of the Dashboard State & URL Sharing guide.

Operative rule: the frame owns the state and the host owns the URL — the frame never tries to write the address bar, and the host never reaches into the frame to read it.

Why the Boundary Forces This Shape

A cross-origin iframe cannot read or write the top-level location, and that restriction is the whole design constraint. It is also, on reflection, the correct arrangement: a reader shares the page they are looking at, which is the host, not the embedded document. If the frame could rewrite the address bar it would be claiming ownership of a page it does not control.

So state flows outward as messages and inward as messages. The frame is the authority on what the map is showing; the host is the authority on what the URL says. Each writes only its own side.

Who owns what across the frame boundary The embedded map owns the camera, the layer state and the selection, and posts a state message outward after movement settles. The host page owns the URL and history, receives that message and writes the fragment. On load the host reads the fragment and posts a restore message inward. Two documents, one shared view, no shared memory the frame — owns the state camera, layers, selection posts state after moveend applies a restore message on load the host — owns the URL writes the fragment and history debounces before writing posts a restore message inward state restore A frame that writes only its own URL produces links that open a bare map with no dashboard around it.

Production-Ready Implementation

// ── inside the embedded map document ──────────────────────────────────────
const HOST_ORIGIN = "https://dashboard.example.org";   // exact, never a prefix
const CHANNEL = "geo-dashboard:v1";

function postState(map, layerState) {
  const c = map.getCenter();
  parent.postMessage({
    channel: CHANNEL,
    type: "state",
    payload: {
      lon: Number(c.lng.toFixed(5)),
      lat: Number(c.lat.toFixed(5)),
      zoom: Number(map.getZoom().toFixed(2)),
      layers: layerState,
    },
  }, HOST_ORIGIN);                                     // targeted, not "*"
}

map.on("moveend", () => postState(map, currentLayerState()));

window.addEventListener("message", (event) => {
  if (event.origin !== HOST_ORIGIN) return;            // strict equality
  const data = event.data;
  if (!data || data.channel !== CHANNEL || data.type !== "restore") return;
  applyState(map, data.payload);                       // validated inside
});

// ── inside the host page ──────────────────────────────────────────────────
const FRAME_ORIGIN = "https://maps.example.org";
const frame = document.getElementById("map-frame");
let pending = null;

window.addEventListener("message", (event) => {
  if (event.origin !== FRAME_ORIGIN) return;
  if (event.source !== frame.contentWindow) return;    // this frame, not another
  const data = event.data;
  if (!data || data.channel !== CHANNEL || data.type !== "state") return;

  clearTimeout(pending);                               // debounce host-side too
  pending = setTimeout(() => {
    const url = new URL(window.location.href);
    url.hash = encodeState(data.payload);
    window.history.replaceState(null, "", url);
  }, 250);
});

frame.addEventListener("load", () => {
  frame.contentWindow.postMessage({
    channel: CHANNEL, type: "restore",
    payload: decodeState(window.location.hash),
  }, FRAME_ORIGIN);
});

The three guards on each listener — exact origin, expected channel and, on the host, the specific contentWindow — are what make this safe on a page that may embed several frames. Anything less is an open channel that any embedded document can post into.

Where the Debounce Belongs

Two debounce points and what each prevents Posting only on moveend rather than on move prevents sixty messages a second crossing the boundary during a drag. Delaying the host's history write by a quarter of a second prevents a rapid sequence of settles from producing several URL rewrites. Together they turn a long drag into one URL update. Debounce twice — the boundary is not free to cross in the frame — post on moveend, never on move prevents ~60 structured-clone messages per second during a drag in the host — delay the history write by ~250 ms coalesces a burst of settles into a single URL rewrite Result: a ten-second drag across the map produces one message burst and one URL update.

Loading Order Is the Other Half

The frame’s load event fires when its document is ready, but the map inside it may not be. Posting a restore message at that moment can arrive before anything is listening, and the link silently opens at the default view.

The robust handshake is for the frame to announce itself: once the map is constructed and its listener is attached, it posts a ready message outward, and the host replies with restore. That removes the race entirely and costs one extra message.

The ready-then-restore handshake The host renders the frame. The frame's document loads and constructs the map and its message listener, then posts a ready message. The host replies with a restore message carrying the decoded URL state. The frame applies it and posts its first state message back, closing the loop. Four messages, no race 1 · frame loads map + listener ready 2 · frame → "ready" only now can it receive 3 · host → "restore" decoded from the URL 4 · applied first paint Without the handshake the host posts restore on the frame's load event, which can precede the map's listener — the message is dropped, and the deep link opens at the default view with no error anywhere. It reproduces only on slow connections, which is why it survives testing.

Verification Steps

  • Throttle the network to a slow profile and confirm a deep link still restores — that is the case the handshake exists for.
  • Embed two frames from the same origin and confirm each responds only to its own messages.
  • Drag for ten seconds and count history entries: there should be one.
  • Post a message from an unexpected origin in the console and confirm both sides ignore it.
  • Confirm the host’s URL, not the frame’s, is what a reader copies from the share control.

Common Errors & Fixes

Nothing arrives at the host

postMessage is targeting the wrong origin, or the frame is same-origin and the code is comparing against a stale constant. Log event.origin once during development and pin the exact value.

State arrives but the URL never changes

The host is writing to the frame’s URL by mistake — usually frame.contentWindow.location instead of window.location. Only the host may touch the host’s URL.

The back button reloads the whole frame

The host is calling pushState on every state message. Use replaceState for camera movement and reserve pushState for discrete decisions, as covered in the camera state guide.

Messages work locally and fail in production

The production embed is served from a different origin than the one hard-coded — often www versus bare domain. Generate both origin constants from the same build configuration that generates the embed markup.

Versioning the Channel

The message contract between a host page and an embedded map is an interface between two deployments that will not be released together. The host may be a site the map team does not control; the map may be embedded on pages nobody has an inventory of. Both will change.

A version in the channel name — geo-dashboard:v1 rather than geo-dashboard — makes that survivable. A frame that speaks v1 ignores v2 messages instead of misreading them, and a host that has been upgraded can send both during a transition. The alternative, an unversioned channel whose payload shape changes, produces a failure that is silent on both sides: messages arrive, are parsed as something they are not, and the map does something unexpected.

The practical transition looks like this. The frame starts accepting v2 while still accepting v1. Hosts upgrade at their own pace. Once telemetry — or simply time — suggests nobody is sending v1, the frame drops it. At no point is there a moment when both sides must deploy together, which is the property that makes the whole arrangement workable across organisational boundaries.

It is worth documenting the contract somewhere a host developer will find it, with the message types, the payload shapes and the origins involved. An embed integration that requires reading the frame’s source to discover what messages it accepts is one that will be integrated incorrectly.

What to Do When the Host Is Not Yours

Plenty of embeds live on pages the map team has no access to. That constrains the design in two useful ways.

First, the frame must be fully usable with no messages at all. Every state it needs to open correctly has to be expressible in its own URL, because a host that never posts a restore message is a host that only ever loads the frame’s src. Treat the messaging channel as an enhancement over a self-sufficient frame, not as a requirement.

Second, the frame should announce what it can do rather than assume the host knows. A ready message that carries a small capability list — the channel version and the message types it accepts — lets a host integrate against what is actually there. It costs a few bytes once per load and removes an entire category of support conversation.

Gotchas & Edge Cases

  • event.origin for a srcdoc frame is "null", so a frame embedded that way cannot be validated by origin at all — serve the map from a real URL if the channel matters.
  • Structured cloning copies the payload, so large state objects cost real time to send; keep messages small and send references rather than data.
  • A frame that is removed and re-added to the DOM gets a new contentWindow, invalidating any stored reference — look it up at send time rather than caching it.
  • Sandbox attributes that omit allow-scripts prevent the frame from posting anything at all, which presents as a channel that silently never connects.
  • Browser extensions and analytics scripts also post messages on the same window; the channel field is what stops their traffic being parsed as map state.