fix(worker): inbound message event.target is self, not null#264
Conversation
…arget is self
Inside a worker, an inbound `message`/`messageerror` event had `event.target`
and `event.currentTarget === null` instead of `self`, violating WHATWG
DedicatedWorkerGlobalScope (browsers and Deno set both to self). The worker-side
scope hand-invoked each listener with a constructed MessageEvent that never went
through EventTarget dispatch, so the target slots were never populated.
Route inbound message/messageerror through a private EventTarget and shadow
`target`/`currentTarget` = self as own data properties on the event (globalThis
cannot be a genuine EventTarget without prototype re-parenting). parentPort
ref-counting for worker lifetime is preserved by mediating every add/remove and
implementing once/signal in-band, attaching one parentPort forwarder per type on
the 0<->1 listener boundary.
Verified on the fast tier (Node 24, 26) and compat tier (Node 20): both
addEventListener('message') and self.onmessage observers now see target ===
currentTarget === self over one shared event.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes — a worker-polyfill fix so an inbound message/messageerror event's target and currentTarget are self during the handler (WHATWG DedicatedWorkerGlobalScope), instead of the pre-fix null.
- Dispatch inbound events through a real
EventTarget— the worker-side scope now dispatches the one sharedMessageEventthrough a privateinboundEventTarget rather than hand-invokingcb.call(scope, new MessageEvent(...)), which never went through dispatch and left the target slots null. - Shadow
target/currentTargettoself— set as own data properties on the event since globalThis can't be a genuineEventTargetwithout prototype re-parenting; native dispatch still drives listener ordering andstopImmediatePropagationover the single event object. - Preserve parentPort ref-counting —
attachPort/detachPortmediate a single per-type forwarder on the 0↔1 listener boundary, with{once}/{signal}implemented in-band so every removal stays observable to the count; a worker with no message listener still unrefs and exits naturally. handleEventbinding + map rename — object listeners now bind viahandleEvent.bind(listener)(spec-correctthis); thewrappersmap is renamed toregisteredwith all references updated.- Test + fixtures — new
worker_inbound_message_target_is_selfasserts bothaddEventListener('message')andself.onmessageseetarget === currentTarget === selfin registration order; file-worker fixtures work on all tiers.
The ref-counting, dedup, exact-wrapper removal, and dispatch-ordering invariants all hold under trace, and the {once} self-removal mid-dispatch is spec-legal (removing the currently-invoked listener skips no later one). The two divergences I looked at are inconsequential and either documented or pre-existing: currentTarget persists as self post-dispatch rather than resetting to null (worker code only reads it during the handler), and a user-synthesized self.dispatchEvent(new MessageEvent("message")) still routes to the other target rather than inbound — unchanged from before this PR. Clean to merge.
Claude Opus | 𝕏
There was a problem hiding this comment.
Pull request overview
Fixes worker-side inbound message / messageerror event dispatch so handlers observe spec-correct identity (event.target === self and event.currentTarget === self) by routing inbound events through an internal EventTarget while preserving Node’s parentPort ref-counting semantics.
Changes:
- Reworks worker-side
message/messageerrorlistener plumbing to dispatch via a privateEventTargetand attach/detach a singleparentPortforwarder per event type at the 0↔1 listener boundary. - Adds a new worker fixture pair to assert
target/currentTargetidentity across bothaddEventListener('message')andself.onmessage. - Adds an integration test covering the new behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
runtime/worker-polyfill.mjs |
Switches inbound message delivery to real EventTarget dispatch with listener-count mediated parentPort forwarding. |
tests/fixtures/worker/event-target-self-worker.ts |
Worker-side fixture asserting inbound event target/currentTarget identity and handler ordering. |
tests/fixtures/worker/event-target-self-main.ts |
Main-side driver fixture that posts to the worker and prints observed identities for harness assertions. |
crates/nub-cli/tests/integration.rs |
Adds integration coverage for inbound message event target/currentTarget being self. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function forward(evt, data) { | ||
| const ev = new MessageEvent(evt, { data }); | ||
| // Shadow the inherited accessors so worker code observes the spec-required | ||
| // DedicatedWorkerGlobalScope as target/currentTarget instead of `inbound`. | ||
| Object.defineProperty(ev, "target", { value: scope, configurable: true }); | ||
| Object.defineProperty(ev, "currentTarget", { value: scope, configurable: true }); | ||
| inbound.dispatchEvent(ev); | ||
| } |
|
Shipped in v0.3.0: https://github.com/nubjs/nub/releases/tag/v0.3.0 |

Inside a nub Worker, an inbound
messageevent exposed the wrong target identity:WHATWG DedicatedWorkerGlobalScope dispatches inbound events on the worker global, so both are
selfduring the handler (browsers and Deno agree). The polyfill hand-invoked each listener with a constructedMessageEventthat never went throughEventTargetdispatch, so the target slots stayed null.Fix
The worker-side
message/messageerrorpath dispatches the one shared event through a privateEventTarget, withtarget/currentTargetshadowed toselfas own properties (globalThis can't be a realEventTargetwithout prototype re-parenting, which the polyfill avoids). parentPort ref-counting — a worker with no message listener must still exit naturally — is preserved by mediating add/remove and implementing{once}/{signal}in-band, attaching one parentPort forwarder per type on the 0-to-1 listener boundary.Verification
Both
addEventListener('message')andself.onmessagenow seetarget === currentTarget === selfon the fast tier (Node 24, 26) and compat tier (Node 20). New testworker_inbound_message_target_is_self; the 22 existing worker tests stay green; clippy and fmt clean. The main-threadWorker.onerror/onmessagetarget (the Worker instance) is spec-correct and untouched.Refs the
worker-event-target-nullthread. Held for maintainer review -- behavior change on a shipped worker surface.