diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index a65be634a..43ea7d6f8 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,28 @@ 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({ + scopedSelector: scopeSelector(selector), + handler, + }); }, undelegateEvents(elem, type, handler) { @@ -96,16 +108,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 +159,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(); + // 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) => { + 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..3f3f69327 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -1222,3 +1222,16 @@ {{item.msg}}-{{logRenderItem item}} {{/each}} + + + + + + diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index ca051cb73..92d2a5e59 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -4699,3 +4699,138 @@ 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); + } +); + +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); + } +);