feat: support deferred target in PostMessageTransport for dynamically created iframes#543
Conversation
…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
Dismissing — want to discuss internally before formal review.
|
Thanks again for the thorough diagnosis in #542 and for putting this together — the root cause (host attaches its 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:
What I'd like to do instead:
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 ( |
Problem
PostMessageTransportrequiresiframe.contentWindowat construction time for botheventTarget(send) andeventSource(receive validation). This creates a race condition for hosts that load View HTML dynamically viasrcdoc— the standard pattern when fetchingui://resources.The iframe starts executing immediately when
srcdocis set, so the View'sApp.connect()sendsui/initializebefore 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 initializesWhy this affects all srcdoc hosts
Any host that:
ui://resource (or receives it inline)iframe.srcdoccontentWindowfor 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:eventTargetacceptsnull— outgoing messages are queued untilsetTarget()is calledeventSourceacceptsnull— all message sources are accepted (useful before the iframe loads)setTarget(target, eventSource?)method — sets the target window, updates the event source (defaults totarget), and flushes all queued messagesclose()clears the queue — prevents stale messages from leakingCorrect 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 initializedHost usage
Backward Compatibility
Fully backward compatible. Existing code works identically:
The only changes to the constructor signature:
eventTarget: Window→eventTarget: Window | null(default unchanged:window.parent)eventSource: MessageEventSource→eventSource: MessageEventSource | null