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
157 changes: 128 additions & 29 deletions packages/blaze/dombackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,21 @@ DOMBackend.parseHTML = function (html) {
return Array.from(template.content.childNodes);
};

// WeakMap for native event delegation: elem -> Map<handler, Array<{wrapper, eventType}>>
// Native event delegation, mirroring jQuery's: one listener per element and
// event type, dispatching to every delegated handler bound there.
// elem -> Map<eventType, {listener, delegates: Array<{scopedSelector, handler}>}>
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).
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/spacebars-tests/template_tests.html
Original file line number Diff line number Diff line change
Expand Up @@ -1222,3 +1222,16 @@
<span>{{item.msg}}-{{logRenderItem item}}</span>
{{/each}}
</template>

<template name="spacebars_test_event_nested_matches">
<div class="item outer"><div class="item inner"><button type="button" class="hit">go</button></div></div>
</template>

<template name="spacebars_test_event_nested_row">
<div class="row"><button type="button" class="del">x</button></div>
</template>

<template name="spacebars_test_event_child_combinator">
<div class="card top"><button type="button" class="hit">go</button></div>
<div class="wrap"><div class="card nested"><button type="button" class="hit">go</button></div></div>
</template>
135 changes: 135 additions & 0 deletions packages/spacebars-tests/template_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
);
Loading