From 68da4ced2cbf40a762ed8fb0626e83e8ec6f53ed Mon Sep 17 00:00:00 2001 From: Jonathan Lippincott Date: Sat, 10 Oct 2026 17:44:23 +0200 Subject: [PATCH 1/2] fix(blaze): restore the native event's currentTarget after a handler runs The native (no-jQuery) backend shows handlers the matched element as currentTarget by defining an own currentTarget property on the native event, and never removed it. Every later listener of the same dispatch, e.g. a document-level addEventListener, then read the element the Blaze handler matched instead of its own currentTarget, and the event kept reporting that element after dispatch, where the native getter returns null. In jQuery mode handlers receive a jQuery event object and the native event is never modified. Delete the own property once the handler returns (also when it throws), so the native getter is visible again. This covers both delegated and capturing handlers, which share createWrapper. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/blaze/dombackend.js | 26 ++++++--- packages/spacebars-tests/template_tests.html | 4 ++ packages/spacebars-tests/template_tests.js | 55 ++++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index a65be634a..3a70f9d86 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -144,6 +144,24 @@ DOMBackend.Events = { } }; +// Mimic jQuery's delegated event behavior: handlers see the matched element +// as currentTarget. jQuery set it on its own event object; here it is an own +// property shadowing the native getter on the native event, so remove it +// again once the handler returns. Otherwise every later listener of the same +// dispatch (e.g. on document) and any code holding on to the event would read +// the stale element instead of its own currentTarget. +const withCurrentTarget = (event, currentTarget, callback) => { + Object.defineProperty(event, 'currentTarget', { + value: currentTarget, + configurable: true, + }); + try { + return callback(); + } finally { + delete event.currentTarget; + } +}; + 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 +191,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..d660419d6 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -4699,3 +4699,58 @@ Tinytest.add( 'stale DOM: surviving view still shows old data after update'); } ); + +// Event handlers see the matched element as currentTarget, but the event must +// not keep it: in jQuery mode handlers get a jQuery event object and the +// native event is untouched, so later listeners of the same dispatch see +// their own currentTarget, and it is null once the dispatch is over. +Tinytest.add( + 'spacebars-tests - template_tests - delegated event restores currentTarget for later listeners', + function (test) { + const tmpl = Template.spacebars_test_event_current_target; + const seen = []; + tmpl.events({ + 'click .card': function (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.isTrue(evt.currentTarget === null); + document.body.removeChild(div); + } +); + +Tinytest.add( + 'spacebars-tests - template_tests - capturing event restores currentTarget', + function (test) { + const tmpl = new Template( + Template.spacebars_test_event_current_target.viewName, + Template.spacebars_test_event_current_target.renderFunction); + const seen = []; + tmpl.events({ + 'mouseenter .card': function (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.isTrue(evt.currentTarget === null); + document.body.removeChild(div); + } +); From bd237ea634eaf8fa6f2b4b621df5adbf265df3d8 Mon Sep 17 00:00:00 2001 From: Jonathan Lippincott Date: Sat, 17 Oct 2026 20:15:24 +0200 Subject: [PATCH 2/2] fix(blaze): keep currentTarget for async native-backend handlers Deleting the own currentTarget once the handler returned also took it away from the handler itself: an async handler that read evt.currentTarget after an `await` got null on the native backend, while the jQuery.Event it receives in jQuery mode keeps the matched element. The handler's event and the native event are the same object here, so currentTarget is now an own getter instead: the matched element while the handler runs and once the dispatch is over (eventPhase NONE), and the native value in between, so later listeners of the same dispatch still see their own currentTarget. The native getter is looked up on the event's own prototype chain, so events from a same-origin iframe use their realm's getter. The two existing tests now expect the matched element after the dispatch, and a new test reads evt.currentTarget after an awaited timer. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/blaze/dombackend.js | 22 ++++++---- packages/spacebars-tests/template_tests.js | 49 ++++++++++++++++++---- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index 3a70f9d86..350f8ed11 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -145,20 +145,28 @@ DOMBackend.Events = { }; // Mimic jQuery's delegated event behavior: handlers see the matched element -// as currentTarget. jQuery set it on its own event object; here it is an own -// property shadowing the native getter on the native event, so remove it -// again once the handler returns. Otherwise every later listener of the same -// dispatch (e.g. on document) and any code holding on to the event would read -// the stale element instead of its own currentTarget. +// 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', { - value: 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 { - delete event.currentTarget; + inHandler = false; } }; diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index d660419d6..797868ca7 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -4700,17 +4700,21 @@ Tinytest.add( } ); -// Event handlers see the matched element as currentTarget, but the event must -// not keep it: in jQuery mode handlers get a jQuery event object and the -// native event is untouched, so later listeners of the same dispatch see -// their own currentTarget, and it is null once the dispatch is over. +// 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 restores currentTarget for later listeners', + '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); }, }); @@ -4726,20 +4730,22 @@ Tinytest.add( document.removeEventListener('click', onDocument); test.equal(seen, ['card', 'document']); - test.isTrue(evt.currentTarget === null); + test.equal(handlerEvent.currentTarget.className, 'card'); document.body.removeChild(div); } ); Tinytest.add( - 'spacebars-tests - template_tests - capturing event restores currentTarget', + '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); }, }); @@ -4750,7 +4756,34 @@ Tinytest.add( div.querySelector('.card').dispatchEvent(evt); test.equal(seen, ['card']); - test.isTrue(evt.currentTarget === null); + 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); } );