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}}
+
+
+ title
+
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);
+ }
+);