Skip to content

feat(transport): support deferred target in PostMessageTransport for srcdoc iframes#543

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

feat(transport): support deferred target in PostMessageTransport for srcdoc iframes#543
netanelavr wants to merge 1 commit intomodelcontextprotocol:mainfrom
netanelavr:fix/deferred-postmessage-target

Conversation

@netanelavr
Copy link

@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.

This is the likely root cause of #476 (ontoolinput not consistently called).

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

Changes

File What changed
src/message-transport.ts Added _sendQueue, setTarget(), null-safe send()/close(), updated JSDoc
src/message-transport.examples.ts Added 3 type-checked examples for deferred usage
src/message-transport.test.ts New — 4 unit tests covering queue/flush, receive-before-target, backward compat, cleanup

Tests

4 focused tests (80/20 principle):

  1. Queue + flush — messages queued when target is null, flushed in order on setTarget()
  2. Receive before target — null eventSource accepts messages immediately (no init race)
  3. Backward compat — existing (window, window) constructor sends immediately, no queue
  4. Cleanupclose() clears the send queue

All 91 tests pass (87 existing + 4 new):

bun test v1.3.10
 91 pass
  0 fail
Ran 91 tests across 4 files.

Related

Background

I discovered this race condition while building an MCP Apps host. Our workaround was a separate DeferredPostMessageTransport class, but the fix belongs in the SDK itself. The approach in this PR is minimal — it enhances the existing class rather than adding a new one, keeping the API surface small and discoverable.

…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
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.

PostMessageTransport: Race condition with srcdoc iframes — host misses ui/initialize

1 participant