You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
But the View's App.connect() runs during step 2-3, sending ui/initializebefore 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:
Fetches HTML from a ui:// resource (or receives it inline)
Sets it via iframe.srcdoc
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:
classDeferredPostMessageTransportimplementsTransport{privatetarget: Window|null=null;privatesendQueue: JSONRPCMessage[]=[];constructor(){// No contentWindow needed at constructionthis.messageListener=(event: MessageEvent)=>{if(event.origin!=='null')return;// srcdoc iframes have 'null' originconstparsed=JSONRPCMessageSchema.safeParse(event.data);if(parsed.success)this.onmessage?.(parsed.data);};}asyncstart(): Promise<void>{window.addEventListener('message',this.messageListener);}asyncsend(message: JSONRPCMessage): Promise<void>{if(this.target){this.target.postMessage(message,'*');}else{this.sendQueue.push(message);}}setTarget(target: Window): void{this.target=target;for(constmsgofthis.sendQueue){this.target.postMessage(msg,'*');}this.sendQueue=[];}asyncclose(): 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:
Make eventTarget optional (default to null)
Add a setTarget(target: Window) method that sets the target and flushes queued messages
Queue outgoing messages in send() when target is null
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.
Summary
PostMessageTransportrequiresiframe.contentWindowat construction time for botheventTargetandeventSource. This creates a race condition for hosts that load View HTML dynamically viasrcdoc(the standard pattern when fetchingui://resources): the iframe starts executing before the host's transport is listening, causing theui/initializerequest to be silently lost.This is the likely root cause of #476 (
ontoolinputnot consistently called) — when the init handshake is dropped, the bridge never reaches the initialized state, so subsequent notifications liketool-inputare never sent.The Race Condition
The current
PostMessageTransportconstructor signature:On the host side, the typical flow is:
ui://resourceiframe.srcdoc = htmliframe.onloadPostMessageTransport(iframe.contentWindow!, iframe.contentWindow!)bridge.connect(transport)→transport.start()begins listeningBut the View's
App.connect()runs during step 2-3, sendingui/initializebefore 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 firesWhy This Affects All srcdoc Hosts
Any host that:
ui://resource (or receives it inline)iframe.srcdoccontentWindowfor the transport constructorwill 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
DeferredPostMessageTransportthat decouples construction from target availability: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 worksProposed Fix
Rather than requiring a separate class,
PostMessageTransportitself could support deferred targets natively. A minimal change:eventTargetoptional (default tonull)setTarget(target: Window)method that sets the target and flushes queued messagessend()when target isnullclose()This is fully backward compatible — existing code passing
(contentWindow, contentWindow)works identically. Hosts that need deferred initialization simply passnull(or omit the arg) and callsetTarget()later.Submitted a PR with this approach: #543