Skip to content

fix(blaze): run native delegated handlers for every nested match, innermost element first, like jQuery - #521

Open
drone1 wants to merge 2 commits into
meteor:fix/native-jquery-delegationfrom
drone1:fix/native-delegation-nested-matches
Open

drone1 wants to merge 2 commits into
meteor:fix/native-jquery-delegationfrom
drone1:fix/native-delegation-nested-matches

Conversation

@drone1

@drone1 drone1 commented Oct 10, 2026 •

Copy link
Copy Markdown

Fixes #519

The native (no-jQuery) delegateEvents added one listener per handler, and each listener called its handler only for the closest ancestor-or-self of event.target that matched the selector. jQuery's delegated dispatch walks from event.target up 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:

  • nested matches ('click .item' with .item inside .item) fired once instead of once per matching element;
  • with 'click .row' bound before 'click .del', clicking .del ran the .row handler first, and stopPropagation() in the .del handler did not stop the .row handler;
  • a handler returning false called stopImmediatePropagation(), so other handlers matching the same element never ran. jQuery only calls preventDefault() + stopPropagation().

This PR replaces the per-handler listeners with a native counterpart of jQuery.event.dispatch:

  • delegateEvents keeps one bubbling listener per element and event type (still aliasing focus/blur to focusin/focusout and stripping namespaces through parseEventType), with the handlers in bind order. undelegateEvents removes the handler for that event type, and removes the listener along with the last handler, like jQuery's .off().
  • dispatchDelegatedEvent builds the handler queue before running anything, as jQuery does. It walks from event.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's querySelectorAll set is computed lazily, once per selector per event, and only after a cheap matches() prefilter.
  • The queue runs level by level with currentTarget (and this) set to the level's element. stopPropagation() or a false return lets the rest of the current level run and then stops the climb. stopImmediatePropagation() stops everything. The native event exposes stopPropagation() only through cancelBubble, which an earlier listener on the same element may already have set, and stopImmediatePropagation() 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.
  • Handler arrays are replaced rather than mutated, so binding or unbinding from inside a handler doesn't change a dispatch in progress (jQuery snapshots its queue too). That includes HandlerRec's capture-mode switch and EventSupport.listen's rebinding of enclosing ranges.

Blaze's bind-order tie-break is preserved: EventSupport.listen moves 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:

  • I added regression tests after the Native (no-jQuery) event backend silently drops delegated events: scope heuristic compares view.name strings, not view identity #512 tests in spacebars-tests/template_tests.js, each on a fresh copy of a test template. They cover nested matches (innermost first), stopPropagation() stopping the climb, a false return stopping the climb but not the other handlers of the same element (and calling preventDefault()), stopImmediatePropagation(), and 'click .row' + 'click .del' with and without stopPropagation(). 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 .del handler stops propagation. The stopPropagation() and stopImmediatePropagation() tests already pass before the fix and guard the new stop handling.
  • I ran blaze and spacebars-tests locally 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.
  • This conflicts textually with fix(blaze): only fire capturing event handlers for a matching target #518 (capture-mode wrapper) and with the currentTarget restore PR (fix(blaze): restore the native event's currentTarget after a native-backend handler runs #522), since all three touch the event section of dombackend.js. After this PR, createWrapper is only used by bindEventCapturer, and fix(blaze): only fire capturing event handlers for a matching target #518 replaces that use, so whichever of the two lands second should delete createWrapper. 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.
  • As before this PR, the per-level currentTarget is 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), whose withCurrentTarget helper wraps each level here once both land.
  • One jQuery delegation behaviour is still not mirrored, here or before: jQuery.event.handlers skips delegated click handlers for event.button >= 1, and skips levels whose element has disabled === true for click. I left it out to keep this PR to the nesting/ordering semantics, and can add it if wanted.

🤖 Generated with Claude Code

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>
@CLAassistant

CLAassistant commented Oct 10, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@dupontbertrand dupontbertrand left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@drone1

drone1 commented Oct 17, 2026

Copy link
Copy Markdown
Author

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>
@dupontbertrand

Copy link
Copy Markdown

Thanks for the quick turnaround. b66909b fixes it: on Meteor 3.5.3, native 426/426 and jQuery 430/430, and the new 'click > .card' test fails against the previous dombackend.js (ca2fa8f). LGTM.

@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.

@jankapunkt jankapunkt added this to the 3.1 milestone Oct 18, 2026
@jankapunkt
jankapunkt changed the base branch from release-3.1.0 to fix/native-jquery-delegation October 19, 2026 08:34

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants