Repository navigation
fix(blaze): run native delegated handlers for every nested match, innermost element first, like jQuery - #521
Conversation
The native (no-jQuery) delegateEvents registered one listener per handler, and each listener only called its handler for the closest ancestor-or-self of event.target matching the selector. jQuery's delegated dispatch instead walks from event.target up to the delegation root and, at every element on the way, runs each handler whose selector matches it, innermost element first and in bind order within an element. As a result, on the native backend: - 'click .item' with nested .item elements fired once instead of once per matching element; - with 'click .row' bound before 'click .row .del', clicking .del ran the .row handler first, and stopPropagation() in the .del handler did not keep the .row handler from running; - returning false called stopImmediatePropagation(), so other handlers matching the same element never ran (jQuery only stops the climb). Delegate through one listener per element and event type that builds the same handler queue as jQuery.event.dispatch and runs it level by level, stopping the climb on stopPropagation() or a false return value and everything on stopImmediatePropagation(). Selectors are still evaluated rooted at the delegation element (:scope), and focus/blur are still delegated as focusin/focusout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
dupontbertrand
left a comment
There was a problem hiding this comment.
Thanks @drone1, the dispatch mirrors jQuery.event.dispatch closely: one listener per element and type, the queue built before any handler runs, per-level stop semantics, and return false as preventDefault() plus stopPropagation(). Snapshotting the delegates on bind and unbind is a nice touch.
One regression: the unscoped node.matches(delegate.selector) prefilter throws for selectors that start with a combinator. jQuery accepts them in delegation ($(elem).on('click', '> .card', handler)), and so does the beta's native backend through :scope > .card, but Element.matches('> .card') is a syntax error. With an event map entry 'click > .card' and a click inside .card:
| native | jQuery | |
|---|---|---|
release-3.1.0 (beta.0) |
handler runs | handler runs |
| this PR | handler doesn't run: Uncaught SyntaxError: Failed to execute 'matches' on 'Element': '> .card' is not a valid selector. |
handler runs |
Dropping the prefilter fixes it, and the scoped querySelectorAll is already cached per selector for the dispatch. With that change the full suite passes on both backends, including a probe for 'click > .card'. Could you remove it (or skip it for combinator-led parts) and add a test for this selector form?
Verified otherwise on Meteor 3.5.3 with the full Blaze suite (test-in-console in headless Chromium, native mode checked through Blaze._DOMBackend._hasJQuery):
| native | jQuery | |
|---|---|---|
release-3.1.0 (beta.0) |
420/420 | 424/424 |
| this PR | 425/425 | 429/429 |
| this PR without the prefilter, plus my two probes (combinator, async) | 427/427 | 431/431 |
this PR's tests against the beta's dombackend.js |
3 of the 5 new tests fail, as you described |
Same results on Meteor 3.6-rc.0, including the combinator failure.
Agreed on keeping the event.button >= 1 and disabled filtering out of this PR.
|
i'll take a look. thanks. |
The unscoped `node.matches(delegate.selector)` prefilter threw a SyntaxError for selectors that start with a combinator, such as 'click > .card', which jQuery accepts and roots at the delegation element, and which the beta's native backend handled through `:scope > .card`. Drop the prefilter: the scoped querySelectorAll set is already computed at most once per selector per dispatch, which is no more work than beta.0's per-handler querySelectorAll per event. Adds a test binding 'click > .card' that expects a .card directly under the delegation element to fire and a nested one not to. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Thanks for the quick turnaround. b66909b fixes it: on Meteor 3.5.3, native 426/426 and jQuery 430/430, and the new @jankapunkt @nachocodoner the Blaze Tests runs on the new commits of #521 and #522 are waiting for approval (first-time contributor), and the CLA check is still pending on all three PRs. |
Fixes #519
The native (no-jQuery)
delegateEventsadded one listener per handler, and each listener called its handler only for the closest ancestor-or-self ofevent.targetthat matched the selector. jQuery's delegated dispatch walks fromevent.targetup to the delegation element instead, and runs every handler whose selector matches each element on the way. It goes innermost element first, in bind order within an element. On the native backend this meant:'click .item'with.iteminside.item) fired once instead of once per matching element;'click .row'bound before'click .del', clicking.delran the.rowhandler first, andstopPropagation()in the.delhandler did not stop the.rowhandler;falsecalledstopImmediatePropagation(), so other handlers matching the same element never ran. jQuery only callspreventDefault()+stopPropagation().This PR replaces the per-handler listeners with a native counterpart of
jQuery.event.dispatch:delegateEventskeeps one bubbling listener per element and event type (still aliasingfocus/blurtofocusin/focusoutand stripping namespaces throughparseEventType), with the handlers in bind order.undelegateEventsremoves the handler for that event type, and removes the listener along with the last handler, like jQuery's.off().dispatchDelegatedEventbuilds the handler queue before running anything, as jQuery does. It walks fromevent.target(its parent element for a text node) up to, but excluding, the delegation element. At each element it keeps the handlers whose:scope-rooted selector (fix(blaze): match delegated event selectors within delegation root #513) matches. Each element'squerySelectorAllset is computed lazily, once per selector per event, and only after a cheapmatches()prefilter.currentTarget(andthis) set to the level's element.stopPropagation()or afalsereturn lets the rest of the current level run and then stops the climb.stopImmediatePropagation()stops everything. The native event exposesstopPropagation()only throughcancelBubble, which an earlier listener on the same element may already have set, andstopImmediatePropagation()not at all. So both methods are shadowed by own properties on the event for the duration of the dispatch, and those properties are deleted afterwards.HandlerRec's capture-mode switch andEventSupport.listen's rebinding of enclosing ranges.Blaze's bind-order tie-break is preserved:
EventSupport.listenmoves the handlers of enclosing ranges to the end by unbinding and rebinding them, so at the same element a child template's handlers still run before its parent's.Notes:
spacebars-tests/template_tests.js, each on a fresh copy of a test template. They cover nested matches (innermost first),stopPropagation()stopping the climb, afalsereturn stopping the climb but not the other handlers of the same element (and callingpreventDefault()),stopImmediatePropagation(), and'click .row'+'click .del'with and withoutstopPropagation(). Before the fix, three fail on the native backend:["item inner"]instead of["item inner","item outer"];["inner item inner"]instead of["inner item inner","item item inner"]; and["row","del"]instead of["del"]when the.delhandler stops propagation. ThestopPropagation()andstopImmediatePropagation()tests already pass before the fix and guard the new stop handling.blazeandspacebars-testslocally with the CI method (meteor test-packages --driver-package test-in-console, driven headless in Chromium). Before: native 349/352. After: native 352/352, jQuery (--extra-packages=jquery) 356/356. The new tests pass on both backends, so the two backends now agree.currentTargetrestore PR (fix(blaze): restore the native event's currentTarget after a native-backend handler runs #522), since all three touch the event section ofdombackend.js. After this PR,createWrapperis only used bybindEventCapturer, and fix(blaze): only fire capturing event handlers for a matching target #518 replaces that use, so whichever of the two lands second should deletecreateWrapper. I have a local branch with all three combined and conflicts resolved: native 355/355, jQuery 359/359. I can rebase onto whichever lands first.currentTargetis an own property left on the native event after the dispatch. That leak is a separate issue with its own PR (fix(blaze): restore the native event's currentTarget after a native-backend handler runs #522), whosewithCurrentTargethelper wraps each level here once both land.jQuery.event.handlersskips delegatedclickhandlers forevent.button >= 1, and skips levels whose element hasdisabled === trueforclick. I left it out to keep this PR to the nesting/ordering semantics, and can add it if wanted.🤖 Generated with Claude Code