Skip to content

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

Open

Description

@netanelavr

Summary

PostMessageTransport requires iframe.contentWindow at construction time for both eventTarget and eventSource. 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 before the host's transport is listening, causing the ui/initialize request to be silently lost.

This is the likely root cause of #476 (ontoolinput not consistently called) — when the init handshake is dropped, the bridge never reaches the initialized state, so subsequent notifications like tool-input are never sent.

The Race Condition

The current PostMessageTransport constructor signature:

constructor(
  private eventTarget: Window = window.parent,
  private eventSource: MessageEventSource,
)

On the host side, the typical flow is:

  1. Fetch HTML content from ui:// resource
  2. Create iframe, set iframe.srcdoc = html
  3. Wait for iframe.onload
  4. Create PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!)
  5. Call bridge.connect(transport) → transport.start() begins listening

But the View's App.connect() runs during step 2-3, sending ui/initialize before step 5. The message is lost.

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 → ontoolinput never fires
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. This is the standard host pattern per the SDK docs and examples. The examples work because they assume the iframe is already loaded (document.getElementById("app-iframe")), but real hosts create iframes dynamically.

Workaround

I built a DeferredPostMessageTransport that decouples construction from target availability:

class DeferredPostMessageTransport implements Transport {
  private target: Window | null = null;
  private sendQueue: JSONRPCMessage[] = [];

  constructor() {
    // No contentWindow needed at construction
    this.messageListener = (event: MessageEvent) => {
      if (event.origin !== 'null') return; // srcdoc iframes have 'null' origin
      const parsed = JSONRPCMessageSchema.safeParse(event.data);
      if (parsed.success) this.onmessage?.(parsed.data);
    };
  }

  async start(): Promise<void> {
    window.addEventListener('message', this.messageListener);
  }

  async send(message: JSONRPCMessage): Promise<void> {
    if (this.target) {
      this.target.postMessage(message, '*');
    } else {
      this.sendQueue.push(message);
    }
  }

  setTarget(target: Window): void {
    this.target = target;
    for (const msg of this.sendQueue) {
      this.target.postMessage(msg, '*');
    }
    this.sendQueue = [];
  }

  async close(): Promise<void> {
    window.removeEventListener('message', this.messageListener);
    this.sendQueue = [];
    this.onclose?.();
  }
}

This enables the correct ordering:

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

    Host->>Transport: 1. new DeferredPostMessageTransport()
    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 → ontoolinput works
Loading

Proposed Fix

Rather than requiring a separate class, PostMessageTransport itself could support deferred targets natively. A minimal change:

  1. Make eventTarget optional (default to null)
  2. Add a setTarget(target: Window) method that sets the target and flushes queued messages
  3. Queue outgoing messages in send() when target is null
  4. Clear the queue in close()

This is fully backward compatible — existing code passing (contentWindow, contentWindow) works identically. Hosts that need deferred initialization simply pass null (or omit the arg) and call setTarget() later.

Submitted a PR with this approach: #543

Activity

  1. added a commit that references this issue on Mar 8, 2026
    70a7729
  2. kentcdodds commented on Apr 10, 2026

    @kentcdodds

    Hi, I think I’m seeing the same underlying issue, with an additional Safari/WebKit-specific signal.

    I have a generated UI that works in Chromium-based hosts/surfaces, but fails consistently on WebKit-based ones:

    • works: Brave desktop
    • fails: Safari desktop
    • fails: iOS Brave
    • fails: Claude mobile/native app
    • ChatGPT shows the same behavior on Safari

    Important detail: this is the same conversation/widget payload. In Chromium it renders; in WebKit it does not.

    What I’m seeing in logs:

    • the app sends ui/initialize
    • on Safari/WebKit, no JSON-RPC init response ever comes back
    • the adapter times out initialization
    • I only see ui-lifecycle-iframe-render-data snapshots with no usable toolOutput / structuredContent
    • in ChatGPT on Safari I also see: Ignoring message from unknown source

    That last log appears to come from PostMessageTransport source validation, which makes me think this may be a combination of:

    1. the srcdoc / dynamic iframe timing race described in #542, and/or
    2. event.source identity/validation being brittle in Safari/WebKit with nested/proxied iframe setups.

    I suspected this would be CSP related, but it does not look like a CSP failure:

    • the adapter script runs
    • ui/initialize is sent
    • messages are flowing
    • but the handshake / source validation appears to break before usable render data arrives

    So I think #542 is likely the right bucket, and Safari/WebKit may be making the transport failure much easier to trigger. If useful, I can provide more detailed logs.

  3. kentcdodds commented on Apr 15, 2026

    @kentcdodds

    Following up with more specific runtime evidence.

    I debugged a real Safari/WebKit MCP App rendering failure locally and the blocking failure turned out to be in the host sandbox relay, not in app-side render logic. I proved this first in the local Inspector, but the reason I think it belongs here too is that the public ext-apps example host appears to encode the same brittle assumption in examples/basic-host/src/sandbox.ts:

    } else if (event.source === inner.contentWindow) {

    In Safari/WebKit, we observed a real MCP App inner iframe message arrive at the outer sandbox with:

    • event.origin === OWN_ORIGIN
    • event.source === window

    instead of event.source === inner.contentWindow.

    That means the example host can drop legitimate inner-frame messages on Safari/WebKit, including ui/initialize, which prevents the app handshake from completing.

    Why I think this matters here:

    • even if this file is "just an example host", it is very likely to be copied or adapted by implementers
    • if the example encodes a brittle source-identity assumption, downstream hosts can inherit Safari-specific initialization failures
    • this seems adjacent to #542 because the symptom is still failed ui/initialize, but the confirmed failure mode here is different: the message is sent and received by the outer sandbox, then dropped because Safari reports the source differently

    What I confirmed in runtime logs:

    • inner app sends ui/initialize
    • outer sandbox receives the message
    • Safari/WebKit surfaces it as same-origin with event.source === window
    • relay drops it because it is not inner.contentWindow
    • app never completes initialization

    Allowing the Safari same-origin self-source case restored initialization immediately.

    The current example logic is:

    } else if (event.source === inner.contentWindow) {
      if (event.origin !== OWN_ORIGIN) {
        console.error(
          "[Sandbox] Rejecting message from inner iframe with unexpected origin:",
          event.origin,
          "expected:",
          OWN_ORIGIN
        );
        return;
      }
      window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
    }

    The change that fixed the issue locally was:

    } else if (
      event.source === inner.contentWindow ||
      (event.origin === OWN_ORIGIN && event.source === window)
    ) {
      if (event.origin !== OWN_ORIGIN) {
        console.error(
          "[Sandbox] Rejecting message from inner iframe with unexpected origin:",
          event.origin,
          "expected:",
          OWN_ORIGIN
        );
        return;
      }
      window.parent.postMessage(event.data, EXPECTED_HOST_ORIGIN);
    }

    I’m not claiming this is necessarily a spec bug. The spec-level sandbox architecture seems fine. This looks more like a host implementation / example robustness issue for Safari/WebKit.

    It may also be worth documenting in the host guidance that:

    • WebKit can report inner iframe messages differently in nested sandbox setups
    • host implementations should not rely exclusively on strict event.source === inner.contentWindow identity
    • origin validation should remain the primary safety check

    If you’d prefer this tracked separately from #542 since it is not the same confirmed root cause as the srcdoc timing race, I can open a focused follow-up issue specifically about the sandbox example / host relay behavior on Safari/WebKit.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions