## 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: ```typescript 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. ```mermaid 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: 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: ```typescript 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: ```mermaid 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: 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