Repository navigation
perf(ext/web): lazy-init EventTarget listeners table - #33734
Conversation
The EventTarget constructor unconditionally allocated a 5-field default data object plus a separate ObjectCreate(null) for the listeners map. Most short-lived EventTarget instances created on the request/response hot path — AbortSignal in particular, since every `new AbortController()` creates one — never have a listener attached and never need either allocation. Move that allocation behind a lazy check: addEventListener creates the data on first add; removeEventListener and dispatchEvent treat `eventTargetData === undefined` as "no listeners" and short-circuit. `setEventTargetData` (used by webidl.createBranded targets such as MessagePort, WebSocket, and globalThis) is unchanged — those callers deliberately initialize the data eagerly before any user code can run. Bench: `new AbortController()` x1,000,000 (release LTO, mean of 3): | | before | after | delta | |--------------------------|--------:|--------:|-------:| | construction time | 35 ns | 17 ns | -51% | | retained heap per object | 384 B | 128 B | -67% | The per-object heap savings (~256 B) come from eliding both the default-data object and the per-instance listeners map allocation in the no-listener case. Verified: dispatch on a never-listened EventTarget still returns true; addEventListener/removeEventListener/dispatchEvent still behave correctly; AbortController.abort() with and without listener still fires correctly; AbortSignal.timeout, AbortSignal.any, MessageChannel / MessagePort message delivery all still work.
CI lint failure on this PR — leftover destructure from the refactor was never read after the early-return guard. `dispatch(self, event)` re-derives `listeners` internally.
fibibot
left a comment
There was a problem hiding this comment.
Substance is right and well-targeted — AbortSignal is the dominant short-lived EventTarget subclass, and walking the dispatch path confirms lazy init is safe end-to-end:
AbortSignal.signalAbort→runAbortStepsshort-circuits tolistenerCount(this, "abort") > 0before allocating anEventor callingdispatchEvent— andlistenerCountreads throughtarget?.[eventTargetData]?.listeners ?? {}which already returns0for the lazy-undefined case (02_event.js:898–908). So no listener-attached signal never allocates the event, the listeners table, or runsdispatchEvent. ✓defineEventHandler's setter callsthis.addEventListener(name, ...)(02_event.js:1482), which is the lazy-init trigger — sosocket.onopen = ...,signal.onabort = ..., etc. correctly populateeventTargetDatabefore any async event arrives.- All five getter helpers (
getAssignedSlot/getHasActivationBehavior/getHost/getListeners/getMode/listenerCount) already usetarget?.[eventTargetData]?.X— confirmed; they handle undefined data without modification. - Node compat (
_events.mjs:986) already handlesif (!data)returningnew SafeMap(). ✓ setEventTargetDatacallers (02_websocket.ts:92,13_message_port.js:116,01_webgpu.js:892,99_main.js:966) all eagerly init viawebidl.createBrandedpaths, so server-upgraded WebSockets, MessagePorts, WebGPU types, andglobalThisare unchanged. ✓- JS-constructor subclasses (
AbortSignal,WebSocketpublic ctor,BroadcastChannel,FileReader,EventSource,Performance) all callsuper()→ empty new constructor → firstaddEventListenerlazy-inits.
Blocker — lint failing
error[no-unused-vars]: `listeners` is never used
--> ext/web/02_event.js:1077:13
In dispatchEvent:
const data = self[eventTargetData];
if (data === undefined || !data.listeners[event.type]) {
setTarget(event, this);
return true;
}
const { listeners } = data; // <-- never used after
if (getDispatched(event)) { ... }
if (event.eventPhase !== Event.NONE) { ... }
return dispatch(self, event);The original code's const { listeners } = self[eventTargetData]; was used only for the !listeners[event.type] early-return — the rest of the function delegates to dispatch(self, event), which re-fetches self[eventTargetData].listeners internally. The PR ports the early-return correctly, then leaves a stale const { listeners } = data; that was never needed. Just delete that line:
const data = self[eventTargetData];
if (data === undefined || !data.listeners[event.type]) {
setTarget(event, this);
return true;
}
if (getDispatched(event)) { ... }Lint failures on three platforms gate the rest of the build (debug/test jobs are pending). Fixing this unblocks CI.
Non-blocking
removeEventListener style nit
const data = self[eventTargetData];
if (data === undefined || callback === null || !data.listeners[type]) {
return;
}
const { listeners } = data;This is fine — listeners IS used after, for the actual removal loop. Just noting it for symmetry with the dispatchEvent fix above; don't apply the same delete here.
addEventListener lazy-init pattern
let data = self[eventTargetData];
if (data === undefined) {
data = self[eventTargetData] = getDefaultTargetData();
}The two-step data = self[...] = ... works but reads slightly awkwardly. The ??= form would be one operator (assuming the V8 fast path for ??= on object properties is comparable):
self[eventTargetData] ??= getDefaultTargetData();
const { listeners } = self[eventTargetData];The PR description even describes the fix this way ("self[eventTargetData] ??= getDefaultTargetData()"). Stylistic only — current form is correct. If V8's ??= codegen for own-properties is materially worse than the explicit branch, ignore.
CI
3 lint failures, 25 jobs pending. Substance + manual walkthrough is clean — once the unused listeners destructuring is removed and lint goes green, I'd expect this to land cleanly. Holding REQUEST_CHANGES on the lint-blocking dead-line.
|
@fibibot pushed d99a1d2 last tick to drop the dead Re: the |
Summary
The
EventTargetconstructor unconditionally allocated a 5-field default-data object plus a separateObjectCreate(null)for the listeners map. Most short-livedEventTargetinstances on the request/response hot path —AbortSignalin particular, since everynew AbortController()builds one — never have a listener attached and never need either allocation.This PR moves both allocations behind a lazy check:
addEventListenercreates the data on first add (self[eventTargetData] ??= getDefaultTargetData()).removeEventListeneranddispatchEventtreateventTargetData === undefinedas "no listeners" and short-circuit (return /setTarget(event, this); return truerespectively).setEventTargetDatais unchanged —MessagePort,WebSocket,globalThis, and the WebGPU types built viawebidl.createBrandedcontinue to initialize eagerly before any user code can run, since they bypass the JS constructor entirely.The five getter helpers (
getAssignedSlot,getHasActivationBehavior,getHost,getListeners,getMode,listenerCount) already used optional chaining ontarget?.[eventTargetData]?.Xand handleundefinedcorrectly without modification — confirmed by walking each call site in the dispatch path.Numbers
new AbortController(), x 1,000,000, release LTO, mean of 3 runs:The ~256 B per-object heap saving comes from eliding both the default-data object and the per-instance listeners map allocation in the no-listener case. Speed savings come from skipping those two allocations and the field-init loop in the constructor.
These are the realistic win because every modern fetch with
AbortSignal.timeout(...)or a user-sideAbortControllerallocates one of these per request, and the dominant case is a signal whose only consumer issignal.aborted/ the internal[abortAlgos]set — neither of which goes throughaddEventListener.Test plan
true.addEventListener/removeEventListener/dispatchEventstill behave correctly across multiple listeners + removals.removeEventListeneron a target that never had any listeners is a safe no-op.AbortController.abort()fires registered abort listeners.AbortController.abort()with no listener attached still flipssignal.abortedandsignal.reasoncorrectly.AbortSignal.timeout(ms)andAbortSignal.any([...])both still work.MessageChannel+MessagePort.postMessage/onmessagestill delivers messages (this exercises thesetEventTargetData(port)eager-init path).tests/specs/, WPTdom/events/, WPTdom/abort/.