Skip to content

feat: support deferred target in PostMessageTransport for dynamically created iframes#543

Closed
netanelavr wants to merge 1 commit intomodelcontextprotocol:mainfrom
netanelavr:fix/deferred-postmessage-target
Closed

feat: support deferred target in PostMessageTransport for dynamically created iframes#543
netanelavr wants to merge 1 commit intomodelcontextprotocol:mainfrom
netanelavr:fix/deferred-postmessage-target

Conversation

@netanelavr
Copy link
Copy Markdown

@netanelavr netanelavr commented Mar 8, 2026

Problem

PostMessageTransport requires iframe.contentWindow at construction time for both eventTarget (send) and eventSource (receive validation). This creates a race condition for hosts that load View HTML dynamically via srcdoc — the standard pattern when fetching ui:// resources.

The iframe starts executing immediately when srcdoc is set, so the View's App.connect() sends ui/initialize before the host has created and started its transport. The message is silently lost, and the bridge never initializes.

Race condition (before this PR)

sequenceDiagram
    participant Host
    participant Transport as PostMessageTransport
    participant Iframe as Iframe (View)

    Note over Host: 1. Fetch HTML from ui:// resource
    Host->>Iframe: 2. iframe.srcdoc = html
    Note over Iframe: Script executes immediately
    Iframe->>Iframe: App.connect() → transport.start()
    Iframe-->>Host: 3. postMessage: ui/initialize
    Note over Host: MISSED — no listener yet
    Host->>Host: 4. await iframe.onload
    Host->>Transport: 5. new PostMessageTransport(contentWindow, contentWindow)
    Host->>Transport: 6. bridge.connect(transport) → start()
    Note over Transport: Now listening, but ui/initialize was already sent
    Note over Host: Bridge never initializes
Loading

Why this affects all srcdoc hosts

Any host that:

  1. Fetches HTML from a ui:// resource (or receives it inline)
  2. Sets it via iframe.srcdoc
  3. Needs contentWindow for the transport constructor

...will hit this race. The existing examples work because they assume the iframe is already loaded (document.getElementById("app-iframe")), but real-world hosts create iframes dynamically.

Solution

This PR adds deferred target support to PostMessageTransport — no new classes, no breaking changes:

  1. eventTarget accepts null — outgoing messages are queued until setTarget() is called
  2. eventSource accepts null — all message sources are accepted (useful before the iframe loads)
  3. New setTarget(target, eventSource?) method — sets the target window, updates the event source (defaults to target), and flushes all queued messages
  4. close() clears the queue — prevents stale messages from leaking

Correct flow (after this PR)

sequenceDiagram
    participant Host
    participant Transport as PostMessageTransport
    participant Iframe as Iframe (View)

    Host->>Transport: 1. new PostMessageTransport(null, null)
    Host->>Transport: 2. bridge.connect(transport) → start()
    Note over Transport: Listening on window "message"
    Host->>Iframe: 3. iframe.srcdoc = html
    Iframe->>Iframe: App.connect()
    Iframe-->>Transport: 4. postMessage: ui/initialize
    Note over Transport: Received! Bridge handles init
    Transport-->>Iframe: 5. Queue: ui/initialize response (queued)
    Host->>Host: 6. await iframe.onload
    Host->>Transport: 7. setTarget(iframe.contentWindow)
    Note over Transport: Flush queue → response delivered
    Note over Host: Bridge initialized
Loading

Host usage

const iframe = document.createElement("iframe");
iframe.sandbox.add("allow-scripts");
document.body.appendChild(iframe);

// Create transport with deferred target — starts listening immediately
const transport = new PostMessageTransport(null, null);
await bridge.connect(transport);

// Load the View — its ui/initialize will be received
iframe.srcdoc = htmlContent;

// After load, set the target to flush queued outgoing messages
iframe.onload = () => {
  transport.setTarget(iframe.contentWindow!);
};

Backward Compatibility

Fully backward compatible. Existing code works identically:

// This still works exactly as before
const transport = new PostMessageTransport(
  iframe.contentWindow!,
  iframe.contentWindow!,
);
await bridge.connect(transport);

The only changes to the constructor signature:

  • eventTarget: WindoweventTarget: Window | null (default unchanged: window.parent)
  • eventSource: MessageEventSourceeventSource: MessageEventSource | null

…srcdoc iframes

When hosts load View HTML dynamically via srcdoc, iframe.contentWindow
is not available until the iframe loads. The current PostMessageTransport
requires contentWindow at construction, creating a race condition where
the View sends ui/initialize before the host's transport is listening.

This adds deferred target support to PostMessageTransport:
- eventTarget and eventSource now accept null
- New setTarget() method sets the target and flushes queued messages
- send() queues messages when target is null
- close() clears the queue

Fully backward compatible — existing constructor usage is unchanged.

Fixes modelcontextprotocol#542

Made-with: Cursor
@netanelavr netanelavr changed the title feat(transport): support deferred target in PostMessageTransport for srcdoc iframes feat(transport): support deferred target in PostMessageTransport for dynamically created iframes Mar 15, 2026
@netanelavr netanelavr changed the title feat(transport): support deferred target in PostMessageTransport for dynamically created iframes feat: support deferred target in PostMessageTransport for dynamically created iframes Mar 15, 2026
@ochafik ochafik dismissed their stale review April 21, 2026 15:14

Dismissing — want to discuss internally before formal review.

@ochafik
Copy link
Copy Markdown
Contributor

ochafik commented Apr 21, 2026

Thanks again for the thorough diagnosis in #542 and for putting this together — the root cause (host attaches its message listener after the View has already sent ui/initialize) is exactly right.

After sitting with it, I'd rather fix this on the host-construction side than add a deferred-target/queue mode to the transport. A few reasons:

  • Silent-hang failure mode. If a host forgets setTarget(), outbound messages queue forever with no error. Today a dropped ui/initialize at least surfaces as a connect() timeout; the queue turns that fail-fast into a fail-never.
  • eventSource: null widens the trust boundary. During the deferred window any frame on the page can inject JSON-RPC into AppBridge. It's the necessary tradeoff for this design, but it's a default I'd rather not introduce — recent changes (feat(app,app-bridge): guard requests sent before handshake completes #623, feat(app): warn when one-shot handlers are registered after connect() #629, fix(react): StrictMode cleanup + relax late-handler guard for re-reg #631) have been pushing toward stricter, louder-earlier behavior.
  • The race is a host-ordering bug, and the safe ordering is straightforward: iframe.contentWindow exists synchronously once the iframe is in the DOM (it's the about:blank window), so the host can construct the transport and appBridge.connect() before setting srcdoc. That's what examples/basic-host does (implementation.ts — connect first, then send the HTML).

What I'd like to do instead:

  1. Add a "Host construction order" section to the host docs spelling out appendChildnew PostMessageTransport(iframe.contentWindow, iframe.contentWindow)appBridge.connect()then srcdoc = html.
  2. Ship a small PostMessageTransport.forHostIframe(iframe) helper that asserts iframe.isConnected, grabs contentWindow, and returns a ready transport — makes the safe pattern a one-liner without queue semantics.
  3. Improve the View-side timeout message so a dropped ui/initialize says why ("no response within Ns — host may have loaded the View before connecting its transport").

Happy to credit you on that PR if you'd like to take it, or I can put it up and tag you. Either way, closing this one in favor of that direction — really appreciate the work that went into pinning down the race.


@kentcdodds — your Safari case (event.source === window instead of inner.contentWindow) is a WebKit source-identity quirk in examples/basic-host/src/sandbox.ts's relay, separate from the construction-timing race here. Would you mind opening it as its own issue against sandbox.ts? Happy to take a focused fix there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants