diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index a65be634a..350f8ed11 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -144,6 +144,32 @@ DOMBackend.Events = { } }; +// Mimic jQuery's delegated event behavior: handlers see the matched element +// as currentTarget. jQuery sets it on its own event object, which nothing +// else sees and which keeps it, so an async handler can still read it after +// an `await`. Here the handlers get the native event itself, so currentTarget +// is shadowed by an own getter instead: it returns the matched element while +// the handler runs and once the dispatch is over, and the native value in +// between, so later listeners of the same dispatch (e.g. on document) see +// their own currentTarget. +const withCurrentTarget = (event, currentTarget, callback) => { + let inHandler = true; + Object.defineProperty(event, 'currentTarget', { + configurable: true, + get() { + if (inHandler || this.eventPhase === Event.NONE) return currentTarget; + // The native getter, from the event's own prototype chain (an + // event from an iframe has that realm's Event.prototype). + return Reflect.get(Object.getPrototypeOf(this), 'currentTarget', this); + }, + }); + try { + return callback(); + } finally { + inHandler = false; + } +}; + 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 @@ -173,13 +199,9 @@ const createWrapper = (elem, type, selector, handler) => { } if (target) { - // Mimic jQuery's delegated event behavior - Object.defineProperty(event, 'currentTarget', { - value: target, - configurable: true, - }); // mimic jQuery event return false behavior - const value = handler.call(target, event); + const value = withCurrentTarget(event, target, + () => handler.call(target, event)); if (value === false) { event.preventDefault(); event.stopPropagation(); diff --git a/packages/spacebars-tests/template_tests.html b/packages/spacebars-tests/template_tests.html index 13169c49c..86693a9fa 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -1222,3 +1222,7 @@ {{item.msg}}-{{logRenderItem item}} {{/each}} + + diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index ca051cb73..797868ca7 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -4699,3 +4699,91 @@ Tinytest.add( 'stale DOM: surviving view still shows old data after update'); } ); + +// Event handlers see the matched element as currentTarget, but the other +// listeners of the same dispatch must see their own: in jQuery mode handlers +// get a jQuery event object and the native event is untouched. Like that +// jQuery event, the event the handler received still reports the matched +// element once the dispatch is over, so async handlers can read it after an +// `await`. (In jQuery mode that is the jQuery event; the native one is null.) +Tinytest.add( + 'spacebars-tests - template_tests - delegated event shows later listeners their own currentTarget', + function (test) { + const tmpl = Template.spacebars_test_event_current_target; + const seen = []; + let handlerEvent; + tmpl.events({ + 'click .card': function (evt) { + handlerEvent = evt; + seen.push(evt.currentTarget.className); + }, + }); + const div = renderToDiv(tmpl); + document.body.appendChild(div); + const onDocument = function (evt) { + seen.push(evt.currentTarget === document ? 'document' : evt.currentTarget.className); + }; + document.addEventListener('click', onDocument); + + const evt = new MouseEvent('click', { bubbles: true, cancelable: true }); + div.querySelector('.title').dispatchEvent(evt); + document.removeEventListener('click', onDocument); + + test.equal(seen, ['card', 'document']); + test.equal(handlerEvent.currentTarget.className, 'card'); + document.body.removeChild(div); + } +); + +Tinytest.add( + 'spacebars-tests - template_tests - capturing event keeps currentTarget after the dispatch', + function (test) { + const tmpl = new Template( + Template.spacebars_test_event_current_target.viewName, + Template.spacebars_test_event_current_target.renderFunction); + const seen = []; + let handlerEvent; + tmpl.events({ + 'mouseenter .card': function (evt) { + handlerEvent = evt; + seen.push(evt.currentTarget.className); + }, + }); + const div = renderToDiv(tmpl); + document.body.appendChild(div); + + const evt = new MouseEvent('mouseenter', { bubbles: false }); + div.querySelector('.card').dispatchEvent(evt); + + test.equal(seen, ['card']); + test.equal(handlerEvent.currentTarget.className, 'card'); + document.body.removeChild(div); + } +); + +Tinytest.addAsync( + 'spacebars-tests - template_tests - async event handler sees currentTarget after an await', + async function (test) { + const tmpl = new Template( + Template.spacebars_test_event_current_target.viewName, + Template.spacebars_test_event_current_target.renderFunction); + let resume; + const resumed = new Promise((resolve) => { resume = resolve; }); + tmpl.events({ + async 'click .card'(evt) { + // A timer resolves only after the synchronous dispatch is over. + await new Promise((resolve) => setTimeout(resolve, 0)); + resume(evt.currentTarget); + }, + }); + const div = renderToDiv(tmpl); + document.body.appendChild(div); + + div.querySelector('.title').dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true })); + const currentTarget = await resumed; + + test.isTrue(currentTarget === div.querySelector('.card')); + document.body.removeChild(div); + } +);