Repository navigation
PostMessageTransport: Race condition with srcdoc iframes — host misses ui/initialize #542
Description
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:
- Fetch HTML content from
ui://resource - Create iframe, set
iframe.srcdoc = html - Wait for
iframe.onload - Create
PostMessageTransport(iframe.contentWindow!, iframe.contentWindow!) - 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
Why This Affects All srcdoc Hosts
Any host that:
- Fetches HTML from a
ui://resource (or receives it inline) - Sets it via
iframe.srcdoc - Needs
contentWindowfor 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
Proposed Fix
Rather than requiring a separate class, PostMessageTransport itself could support deferred targets natively. A minimal change:
- Make
eventTargetoptional (default tonull) - Add a
setTarget(target: Window)method that sets the target and flushes queued messages - Queue outgoing messages in
send()when target isnull - 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
feat(transport): support deferred target in PostMessageTransport for …
kentcdodds commentedon Apr 10, 2026
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:
Important detail: this is the same conversation/widget payload. In Chromium it renders; in WebKit it does not.
What I’m seeing in logs:
ui/initializeui-lifecycle-iframe-render-datasnapshots with no usabletoolOutput/structuredContentIgnoring message from unknown sourceThat last log appears to come from
PostMessageTransportsource validation, which makes me think this may be a combination of:srcdoc/ dynamic iframe timing race described in#542, and/orevent.sourceidentity/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:
ui/initializeis sentSo I think
#542is 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.kentcdodds commentedon Apr 15, 2026
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-appsexample host appears to encode the same brittle assumption inexamples/basic-host/src/sandbox.ts:In Safari/WebKit, we observed a real MCP App inner iframe message arrive at the outer sandbox with:
event.origin === OWN_ORIGINevent.source === windowinstead 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:
#542because the symptom is still failedui/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 differentlyWhat I confirmed in runtime logs:
ui/initializeevent.source === windowinner.contentWindowAllowing the Safari same-origin self-source case restored initialization immediately.
The current example logic is:
The change that fixed the issue locally was:
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:
event.source === inner.contentWindowidentityIf you’d prefer this tracked separately from
#542since it is not the same confirmed root cause as thesrcdoctiming race, I can open a focused follow-up issue specifically about the sandbox example / host relay behavior on Safari/WebKit.