Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions packages/blaze/dombackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions packages/spacebars-tests/template_tests.html
Original file line number Diff line number Diff line change
Expand Up @@ -1222,3 +1222,7 @@
<span>{{item.msg}}-{{logRenderItem item}}</span>
{{/each}}
</template>

<template name="spacebars_test_event_current_target">
<div class="card"><span class="title">title</span></div>
</template>
88 changes: 88 additions & 0 deletions packages/spacebars-tests/template_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
);
Loading