Status: accepted, and confirmed by measurement (2026-09-19)
Handlers must be attached once and never re-created by a state change. At 100 000 rows, attaching one listener per row per event type is a large, measurable cost. React’s answer was a synthetic event system; that is explicitly out of scope.
onClick={fn} must receive the real Event, with real target,
currentTarget, composedPath() and real preventDefault semantics.stopPropagation expectations.composedPath() and
invokes handlers found along it.Option 3 for a fixed list of bubbling event types (click, input, change,
keydown, keyup, pointerdown, pointerup, …), option 1 for everything
else and for explicit opt-outs.
$firsthand$click), so there is no map to grow, no lookup by node, and nothing
that outlives the node. A symbol key was the first choice and was dropped
because a plain string key keeps the dispatch lookup a monomorphic property
read on every node in composedPath().composedPath(), so shadow roots work; the walk
stops at the mount root and honours stopPropagation by checking
cancelBubble.currentTarget is set per step so handlers behave as they would natively.onClick:native attaches directly,
:capture, :once, :passive map to the corresponding addEventListener
options.Portals: a portal’s DOM is outside the mount root, so the delegation root for portal content is the portal’s physical container, registered once per container.
Measured (npm run bench:micro, raw data in benchmarks/results/micro.json),
2000 rows, Chromium, median of 12 repetitions after 3 warmups, with the
two approaches alternating so neither systematically goes first. “Setup” times
the handler attachment alone — the rows are built first, untimed, because
including DOM construction buries the difference the measurement exists to see.
“Dispatch” clicks every row once.
| Nesting depth | Setup, delegated | Setup, direct | Dispatch, delegated | Dispatch, direct |
|---|---|---|---|---|
| 1 | 0.00 ms | 0.30 ms | 7.70 ms | 8.70 ms |
| 5 | 0.10 ms | 0.40 ms | 8.40 ms | 10.00 ms |
| 20 | 0.10 ms | 0.60 ms | 19.65 ms | 20.25 ms |
Both ends of the trade came out in favour of delegation:
addEventListener. Both are small; the ratio is not.composedPath() walk is longest (19.65 ms against 20.25 ms).This refutes the risk the project recorded against this decision (R4 in
docs/architecture/risks.md), which was that the delegation lookup might cost
more at realistic depths than a native listener dispatch. It does not.
The first version of this measurement timed row construction together with handler attachment and showed the two approaches as identical — DOM building dominated and hid the effect entirely. That version is not the one reported here, and the reason is recorded so the same mistake is not repeated.
One property per handler per node (unavoidable, and it is exactly one reference), plus one listener per event type on the document. No handler registry that outlives the node.
Handlers are ordinary functions receiving ordinary events. e.stopPropagation()
and e.preventDefault() behave as documented by the platform. The documented
difference from direct listeners: a delegated handler runs during the root’s
dispatch, so a native listener attached between the element and the root will
see the event first. The opt-out exists for the rare case where that matters.
Per-node listeners as the default (mount cost at scale), synthetic events (forbidden, allocating, and a re-implementation of the platform).