diff --git a/packages/web-worker/src/utils.ts b/packages/web-worker/src/utils.ts index 2da71f188..3f8aeacfa 100644 --- a/packages/web-worker/src/utils.ts +++ b/packages/web-worker/src/utils.ts @@ -31,16 +31,32 @@ function createClonedMessageEvent( debug('clone worker message %o', data) const origin = typeof location === 'undefined' ? undefined : location.origin const ports = transfer?.filter((t): t is MessagePort => t instanceof MessagePort) - 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. - t => !(t instanceof MessagePort), - ) if (typeof structuredClone === 'function' && clone === 'native') { debug('create message event, using native structured clone') + // A real Worker serializes `data` across realms and exposes the + // MessagePorts from `transfer` as `event.ports` on the receiving side. + // @vitest/web-worker runs both sides in a single realm, so we use + // `structuredClone` to emulate that transfer boundary. + // + // `MessageEvent.ports` must be the *cloned* ports returned by + // `structuredClone`, not the originals from `transfer`: once transferred, + // the originals are detached and can no longer communicate — e.g. + // `port1.postMessage(...)` on the caller side would not trigger + // `port2.onmessage` on a detached `port2`. + // + // `data` and `ports` must also be cloned in the *same* `structuredClone` + // call. A transferred object is detached immediately, so we cannot clone + // `data` first and then clone `ports` (or vice versa) — the second call + // would see already-detached ports. Passing them together as a single + // input also makes `structuredClone` deduplicate by identity, so a port + // referenced from inside `data` and from `transfer` resolves to the same + // transferred instance in the cloned graph. + const { data: clonedData, ports: clonedPorts } = structuredClone({ data, ports }, { transfer }) return new MessageEvent('message', { - data: structuredClone(data, { transfer: transferWithoutPorts }), + data: clonedData, origin, - ports, + ports: clonedPorts, }) } if (clone !== 'none') { diff --git a/test/core/src/web-worker/worker.ts b/test/core/src/web-worker/worker.ts index de29dcddd..b367525b9 100644 --- a/test/core/src/web-worker/worker.ts +++ b/test/core/src/web-worker/worker.ts @@ -1,6 +1,11 @@ self.onmessage = (e) => { self.postMessage(`${e.data} world`) + const portPassedAsData = e.data?.port + if (portPassedAsData) { + portPassedAsData.postMessage(`Reply via port in data`) + } + const port = e.ports[0] if (port) { port.postMessage(`${e.data} world via port`) diff --git a/test/core/test/web-worker-node.test.ts b/test/core/test/web-worker-node.test.ts index fd67a87f8..71a576ece 100644 --- a/test/core/test/web-worker-node.test.ts +++ b/test/core/test/web-worker-node.test.ts @@ -226,7 +226,7 @@ cloneTypes.forEach((clone) => { process.env.VITEST_WEB_WORKER_CLONE = undefined }) - it('transfers MessagePort objects to worker as event.ports', async () => { + it('transfers a MessagePort object in the transfer list to worker as event.ports[0]', async () => { expect.assertions(1) const worker = new MyWorker() @@ -238,6 +238,20 @@ cloneTypes.forEach((clone) => { worker.postMessage('hello', [channel.port2]) await expect(promise).resolves.toBe('hello world via port') }) + + // Skipped for 'ponyfill' because it does not support transferring MessagePort objects. + it.skipIf(clone === 'ponyfill')('transfers a MessagePort object in the data argument to worker and the passed port works', async () => { + expect.assertions(1) + + const worker = new MyWorker() + const channel = new MessageChannel() + const promise = new Promise((resolve, reject) => { + channel.port1.onmessage = e => resolve(e.data as string) + channel.port1.onmessageerror = reject + }) + worker.postMessage({ port: channel.port2 }, [channel.port2]) + await expect(promise).resolves.toBe('Reply via port in data') + }) }) })