Skip to content

Commit 7ad7d39

Browse files
whitphxclaude
andauthored
fix(web-worker): support MessagePort objects referenced inside postMessage data (fix #9927) (#10124)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 325463a commit 7ad7d39

3 files changed

Lines changed: 41 additions & 6 deletions

File tree

packages/web-worker/src/utils.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,32 @@ function createClonedMessageEvent(
3131
debug('clone worker message %o', data)
3232
const origin = typeof location === 'undefined' ? undefined : location.origin
3333
const ports = transfer?.filter((t): t is MessagePort => t instanceof MessagePort)
34-
const transferWithoutPorts = transfer?.filter( // `ports` must be excluded from the `transfer` option passed to `structuredClone` to keep the MessagePort objects working correctly in the same thread.
35-
t => !(t instanceof MessagePort),
36-
)
3734

3835
if (typeof structuredClone === 'function' && clone === 'native') {
3936
debug('create message event, using native structured clone')
37+
// A real Worker serializes `data` across realms and exposes the
38+
// MessagePorts from `transfer` as `event.ports` on the receiving side.
39+
// @vitest/web-worker runs both sides in a single realm, so we use
40+
// `structuredClone` to emulate that transfer boundary.
41+
//
42+
// `MessageEvent.ports` must be the *cloned* ports returned by
43+
// `structuredClone`, not the originals from `transfer`: once transferred,
44+
// the originals are detached and can no longer communicate — e.g.
45+
// `port1.postMessage(...)` on the caller side would not trigger
46+
// `port2.onmessage` on a detached `port2`.
47+
//
48+
// `data` and `ports` must also be cloned in the *same* `structuredClone`
49+
// call. A transferred object is detached immediately, so we cannot clone
50+
// `data` first and then clone `ports` (or vice versa) — the second call
51+
// would see already-detached ports. Passing them together as a single
52+
// input also makes `structuredClone` deduplicate by identity, so a port
53+
// referenced from inside `data` and from `transfer` resolves to the same
54+
// transferred instance in the cloned graph.
55+
const { data: clonedData, ports: clonedPorts } = structuredClone({ data, ports }, { transfer })
4056
return new MessageEvent('message', {
41-
data: structuredClone(data, { transfer: transferWithoutPorts }),
57+
data: clonedData,
4258
origin,
43-
ports,
59+
ports: clonedPorts,
4460
})
4561
}
4662
if (clone !== 'none') {

test/core/src/web-worker/worker.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
self.onmessage = (e) => {
22
self.postMessage(`${e.data} world`)
33

4+
const portPassedAsData = e.data?.port
5+
if (portPassedAsData) {
6+
portPassedAsData.postMessage(`Reply via port in data`)
7+
}
8+
49
const port = e.ports[0]
510
if (port) {
611
port.postMessage(`${e.data} world via port`)

test/core/test/web-worker-node.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ cloneTypes.forEach((clone) => {
226226
process.env.VITEST_WEB_WORKER_CLONE = undefined
227227
})
228228

229-
it('transfers MessagePort objects to worker as event.ports', async () => {
229+
it('transfers a MessagePort object in the transfer list to worker as event.ports[0]', async () => {
230230
expect.assertions(1)
231231

232232
const worker = new MyWorker()
@@ -238,6 +238,20 @@ cloneTypes.forEach((clone) => {
238238
worker.postMessage('hello', [channel.port2])
239239
await expect(promise).resolves.toBe('hello world via port')
240240
})
241+
242+
// Skipped for 'ponyfill' because it does not support transferring MessagePort objects.
243+
it.skipIf(clone === 'ponyfill')('transfers a MessagePort object in the data argument to worker and the passed port works', async () => {
244+
expect.assertions(1)
245+
246+
const worker = new MyWorker()
247+
const channel = new MessageChannel()
248+
const promise = new Promise<string>((resolve, reject) => {
249+
channel.port1.onmessage = e => resolve(e.data as string)
250+
channel.port1.onmessageerror = reject
251+
})
252+
worker.postMessage({ port: channel.port2 }, [channel.port2])
253+
await expect(promise).resolves.toBe('Reply via port in data')
254+
})
241255
})
242256
})
243257

0 commit comments

Comments
 (0)