From ca2fa8f16e429e36cd7fe7c0322b58da2aeea72b Mon Sep 17 00:00:00 2001 From: Jonathan Lippincott Date: Sat, 10 Oct 2026 17:40:36 +0200 Subject: [PATCH 1/2] fix(blaze): run delegated handlers for every nested match, like jQuery 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) --- packages/blaze/dombackend.js | 158 +++++++++++++++---- packages/spacebars-tests/template_tests.html | 8 + packages/spacebars-tests/template_tests.js | 114 +++++++++++++ 3 files changed, 251 insertions(+), 29 deletions(-) diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index a65be634a..3b7999f87 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -53,13 +53,21 @@ DOMBackend.parseHTML = function (html) { return Array.from(template.content.childNodes); }; -// WeakMap for native event delegation: elem -> Map> +// Native event delegation, mirroring jQuery's: one listener per element and +// event type, dispatching to every delegated handler bound there. +// elem -> Map}> const _delegateMap = new WeakMap(); // focus/blur don't bubble — use focusin/focusout for native delegation // (jQuery does this automatically in .on() delegation) const _delegateEventAlias = { focus: 'focusin', blur: 'focusout' }; +const delegatedEventType = (type) => { + const eventType = DOMBackend.Events.parseEventType(type); + // Alias non-bubbling events to their bubbling equivalents + return _delegateEventAlias[eventType] || eventType; +}; + DOMBackend.Events = { // `selector` is non-null. `type` is one type (but // may be in backend-specific form, e.g. have namespaces). @@ -70,24 +78,29 @@ DOMBackend.Events = { return; } - let eventType = DOMBackend.Events.parseEventType(type); - // Alias non-bubbling events to their bubbling equivalents - eventType = _delegateEventAlias[eventType] || eventType; - - const wrapper = createWrapper(elem, type, selector, handler); + const eventType = delegatedEventType(type); if (!_delegateMap.has(elem)) { _delegateMap.set(elem, new Map()); } - const handlerMap = _delegateMap.get(elem); - // Store wrapper keyed by handler for later removal (eventType stored in the entry) - const key = handler; - if (!handlerMap.has(key)) { - handlerMap.set(key, []); + const typeMap = _delegateMap.get(elem); + let delegation = typeMap.get(eventType); + if (!delegation) { + delegation = { delegates: [] }; + delegation.listener = (event) => { + dispatchDelegatedEvent(elem, delegation.delegates, event); + }; + typeMap.set(eventType, delegation); + elem.addEventListener(eventType, delegation.listener); } - handlerMap.get(key).push({ wrapper, eventType }); - - elem.addEventListener(eventType, wrapper); + // Replace rather than mutate the array, so that binding or unbinding + // from inside a handler doesn't change the dispatch in progress + // (jQuery also snapshots its handler queue before running it). + delegation.delegates = delegation.delegates.concat({ + selector, + scopedSelector: scopeSelector(selector), + handler, + }); }, undelegateEvents(elem, type, handler) { @@ -96,16 +109,19 @@ DOMBackend.Events = { return; } - const handlerMap = _delegateMap.get(elem); - if (!handlerMap) return; + const typeMap = _delegateMap.get(elem); + if (!typeMap) return; - const entries = handlerMap.get(handler); - if (!entries) return; + const eventType = delegatedEventType(type); + const delegation = typeMap.get(eventType); + if (!delegation) return; - for (const entry of entries) { - elem.removeEventListener(entry.eventType, entry.wrapper); + delegation.delegates = delegation.delegates.filter( + (delegate) => delegate.handler !== handler); + if (delegation.delegates.length === 0) { + elem.removeEventListener(eventType, delegation.listener); + typeMap.delete(eventType); } - handlerMap.delete(handler); }, bindEventCapturer(elem, type, selector, handler) { @@ -144,15 +160,99 @@ DOMBackend.Events = { } }; +// jQuery delegation evaluates the selector rooted at the delegation +// element ($(elem).find(selector)): for 'div p', both the div and the p +// must live inside `elem`. A bare closest(selector) matches against the +// whole document, letting ancestors outside `elem` satisfy the selector. +const scopeSelector = (selector) => selector + .split(',') + .map((part) => `:scope ${part}`) + .join(','); + +// Native counterpart of jQuery.event.dispatch for delegated handlers. Like +// jQuery, it walks from event.target up to (excluding) `elem` and, at every +// element on the way, runs each handler whose selector matches it, in the +// order the handlers were bound, with currentTarget set to that element. +// Innermost elements run first. A handler that stops propagation (or returns +// false) lets the remaining handlers at its own level run but stops the +// climb; stopImmediatePropagation stops everything. +const dispatchDelegatedEvent = (elem, delegates, event) => { + // event.target can be a text node (nodeType 3) — walk to parent element first + const origin = event.target.nodeType === 1 ? event.target : event.target.parentElement; + + // Build the whole queue before running any handler, as jQuery does, so + // DOM changes made by a handler don't change which handlers run. + const queue = []; + const scopedMatches = new Map(); + const matchesScoped = (delegate, node) => { + // The unscoped matches() is a cheap prefilter: anything the scoped + // selector finds also matches the bare selector. + if (!node.matches(delegate.selector)) return false; + let matches = scopedMatches.get(delegate.scopedSelector); + if (!matches) { + matches = new Set(elem.querySelectorAll(delegate.scopedSelector)); + scopedMatches.set(delegate.scopedSelector, matches); + } + return matches.has(node); + }; + // `elem` itself is excluded: delegated handlers only fire on descendants. + for (let node = origin; node && node !== elem; node = node.parentElement) { + const handlers = delegates + .filter((delegate) => matchesScoped(delegate, node)) + .map((delegate) => delegate.handler); + if (handlers.length) queue.push({ node, handlers }); + } + if (!queue.length) return; + + // The native event only exposes stopPropagation() through cancelBubble, + // which may already be set by another listener on `elem`, and doesn't + // expose stopImmediatePropagation() at all. Track both calls for the + // duration of this dispatch, the way jQuery's event object does. + let propagationStopped = false; + let immediatePropagationStopped = false; + const stopPropagation = event.stopPropagation; + const stopImmediatePropagation = event.stopImmediatePropagation; + Object.defineProperty(event, 'stopPropagation', { + value() { + propagationStopped = true; + return stopPropagation.call(event); + }, + configurable: true, + }); + Object.defineProperty(event, 'stopImmediatePropagation', { + value() { + propagationStopped = true; + immediatePropagationStopped = true; + return stopImmediatePropagation.call(event); + }, + configurable: true, + }); + + try { + for (const { node, handlers } of queue) { + if (propagationStopped) break; + // Mimic jQuery's delegated event behavior + Object.defineProperty(event, 'currentTarget', { + value: node, + configurable: true, + }); + for (const handler of handlers) { + if (immediatePropagationStopped) break; + // mimic jQuery event return false behavior + if (handler.call(node, event) === false) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } finally { + delete event.stopPropagation; + delete event.stopImmediatePropagation; + } +}; + const createWrapper = (elem, type, selector, handler) => { - // jQuery delegation evaluates the selector rooted at the delegation - // element ($(elem).find(selector)): for 'div p', both the div and the p - // must live inside `elem`. A bare closest(selector) matches against the - // whole document, letting ancestors outside `elem` satisfy the selector. - const scopedSelector = selector - .split(',') - .map((part) => `:scope ${part}`) - .join(','); + const scopedSelector = scopeSelector(selector); return (event) => { // event.target can be a text node (nodeType 3) — walk to parent element first diff --git a/packages/spacebars-tests/template_tests.html b/packages/spacebars-tests/template_tests.html index 13169c49c..42039185e 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -1222,3 +1222,11 @@ {{item.msg}}-{{logRenderItem item}} {{/each}} + + + + diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index ca051cb73..57669b51f 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -4699,3 +4699,117 @@ Tinytest.add( 'stale DOM: surviving view still shows old data after update'); } ); + +// Delegated handlers run once for every element between event.target and the +// delegation root that matches their selector, innermost first, as in jQuery +// mode. Handlers bound on the same element run level by level: all handlers +// matching the inner element (in bind order) before any matching an outer one. +const dispatchClick = function (elem) { + const evt = new MouseEvent('click', { bubbles: true, cancelable: true }); + elem.dispatchEvent(evt); + return evt; +}; + +// Each test gets a fresh copy of the template, since events() accumulates. +const renderNestedMatches = function (source, events) { + const tmpl = new Template(source.viewName, source.renderFunction); + tmpl.events(events); + const div = renderToDiv(tmpl); + document.body.appendChild(div); + return div; +}; + +Tinytest.add( + 'spacebars-tests - template_tests - delegated event fires for every nested match, innermost first', + function (test) { + const buf = []; + const div = renderNestedMatches(Template.spacebars_test_event_nested_matches, { + 'click .item': function (evt) { + buf.push(evt.currentTarget.className); + }, + }); + dispatchClick(div.querySelector('.hit')); + test.equal(buf, ['item inner', 'item outer']); + document.body.removeChild(div); + } +); + +Tinytest.add( + 'spacebars-tests - template_tests - delegated event stopPropagation stops nested matches', + function (test) { + const buf = []; + const div = renderNestedMatches(Template.spacebars_test_event_nested_matches, { + 'click .item': function (evt) { + buf.push(evt.currentTarget.className); + evt.stopPropagation(); + }, + }); + dispatchClick(div.querySelector('.hit')); + test.equal(buf, ['item inner']); + document.body.removeChild(div); + } +); + +Tinytest.add( + 'spacebars-tests - template_tests - delegated event return false stops nested matches but not handlers at the same element', + function (test) { + const buf = []; + const div = renderNestedMatches(Template.spacebars_test_event_nested_matches, { + 'click .inner': function (evt) { + buf.push(`inner ${evt.currentTarget.className}`); + return false; + }, + 'click .item': function (evt) { + buf.push(`item ${evt.currentTarget.className}`); + }, + }); + const evt = dispatchClick(div.querySelector('.hit')); + test.equal(buf, ['inner item inner', 'item item inner']); + test.isTrue(evt.defaultPrevented); + document.body.removeChild(div); + } +); + +Tinytest.add( + 'spacebars-tests - template_tests - delegated event stopImmediatePropagation stops all remaining handlers', + function (test) { + const buf = []; + const div = renderNestedMatches(Template.spacebars_test_event_nested_matches, { + 'click .inner': function (evt) { + buf.push('inner'); + evt.stopImmediatePropagation(); + }, + 'click .item': function (evt) { + buf.push(`item ${evt.currentTarget.className}`); + }, + }); + dispatchClick(div.querySelector('.hit')); + test.equal(buf, ['inner']); + document.body.removeChild(div); + } +); + +Tinytest.add( + 'spacebars-tests - template_tests - delegated handlers run innermost element first regardless of bind order', + function (test) { + const buf = []; + const div = renderNestedMatches(Template.spacebars_test_event_nested_row, { + 'click .row': function () { + buf.push('row'); + }, + 'click .del': function (evt) { + buf.push('del'); + if (stopAtDel) evt.stopPropagation(); + }, + }); + let stopAtDel = false; + dispatchClick(div.querySelector('.del')); + test.equal(buf, ['del', 'row']); + + buf.length = 0; + stopAtDel = true; + dispatchClick(div.querySelector('.del')); + test.equal(buf, ['del']); + document.body.removeChild(div); + } +); From b66909bcb3034a9ef9ead0c91f77b58f2e144566 Mon Sep 17 00:00:00 2001 From: Jonathan Lippincott Date: Sat, 17 Oct 2026 20:15:17 +0200 Subject: [PATCH 2/2] fix(blaze): accept combinator-led selectors in native delegated dispatch 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) --- packages/blaze/dombackend.js | 9 ++++----- packages/spacebars-tests/template_tests.html | 5 +++++ packages/spacebars-tests/template_tests.js | 21 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index 3b7999f87..43ea7d6f8 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -55,7 +55,7 @@ DOMBackend.parseHTML = function (html) { // Native event delegation, mirroring jQuery's: one listener per element and // event type, dispatching to every delegated handler bound there. -// elem -> Map}> +// elem -> Map}> const _delegateMap = new WeakMap(); // focus/blur don't bubble — use focusin/focusout for native delegation @@ -97,7 +97,6 @@ DOMBackend.Events = { // from inside a handler doesn't change the dispatch in progress // (jQuery also snapshots its handler queue before running it). delegation.delegates = delegation.delegates.concat({ - selector, scopedSelector: scopeSelector(selector), handler, }); @@ -184,10 +183,10 @@ const dispatchDelegatedEvent = (elem, delegates, event) => { // DOM changes made by a handler don't change which handlers run. const queue = []; const scopedMatches = new Map(); + // Only the scoped selector is valid on its own: a jQuery delegation + // selector may start with a combinator ('> .card'), which matches() + // rejects. Each selector's match set is computed once per dispatch. const matchesScoped = (delegate, node) => { - // The unscoped matches() is a cheap prefilter: anything the scoped - // selector finds also matches the bare selector. - if (!node.matches(delegate.selector)) return false; let matches = scopedMatches.get(delegate.scopedSelector); if (!matches) { matches = new Set(elem.querySelectorAll(delegate.scopedSelector)); diff --git a/packages/spacebars-tests/template_tests.html b/packages/spacebars-tests/template_tests.html index 42039185e..3f3f69327 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -1230,3 +1230,8 @@ + + diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index 57669b51f..92d2a5e59 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -4813,3 +4813,24 @@ Tinytest.add( document.body.removeChild(div); } ); + +Tinytest.add( + 'spacebars-tests - template_tests - delegated event selector may start with a combinator', + function (test) { + // As in jQuery mode, '> .card' is rooted at the delegation element: + // only a .card that is its direct child matches. + const buf = []; + const div = renderNestedMatches(Template.spacebars_test_event_child_combinator, { + 'click > .card': function (evt) { + buf.push(evt.currentTarget.className); + }, + }); + dispatchClick(div.querySelector('.top .hit')); + test.equal(buf, ['card top']); + + buf.length = 0; + dispatchClick(div.querySelector('.nested .hit')); + test.equal(buf, []); + document.body.removeChild(div); + } +);