Skip to content
Merged
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
245 changes: 164 additions & 81 deletions packages/blaze/dombackend.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,84 +2,154 @@ const DOMBackend = {};
Blaze._DOMBackend = DOMBackend;

const $jq = (typeof jQuery !== 'undefined' ? jQuery :
(typeof Package !== 'undefined' ?
Package.jquery && Package.jquery.jQuery : null));
if (! $jq)
throw new Error("jQuery not found");
(typeof Package !== 'undefined' && Package.jquery ?
(Package.jquery.jQuery || Package.jquery.$) : null));

DOMBackend._$jq = $jq;
const _hasJQuery = !!$jq;

if (_hasJQuery && typeof console !== 'undefined') {
console.info(
'[Blaze] jQuery detected as DOM backend. Native DOM backend is available — ' +
'remove the jquery package to enable it. jQuery support will be removed in Blaze 4.0.'
);
}

DOMBackend._$jq = $jq; // null when absent
DOMBackend._hasJQuery = _hasJQuery;

DOMBackend.getContext = function() {
if (DOMBackend._context) {
return DOMBackend._context;
}
if ( DOMBackend._$jq.support.createHTMLDocument ) {
DOMBackend._context = document.implementation.createHTMLDocument( "" );

// Set the base href for the created document
// so any parsed elements with URLs
// are based on the document's URL (gh-2965)
const base = DOMBackend._context.createElement( "base" );
DOMBackend.getContext = function () {
if (DOMBackend._context) return DOMBackend._context;
// jQuery may need the legacy check; native path always supports createHTMLDocument
const useCreateHTMLDocument = _hasJQuery ? $jq.support.createHTMLDocument : true;
if (useCreateHTMLDocument) {
DOMBackend._context = document.implementation.createHTMLDocument("");
const base = DOMBackend._context.createElement("base");
base.href = document.location.href;
DOMBackend._context.head.appendChild( base );
DOMBackend._context.head.appendChild(base);
} else {
DOMBackend._context = document;
}
return DOMBackend._context;
}
};

DOMBackend.parseHTML = function (html) {
// Return an array of nodes.
//
// jQuery does fancy stuff like creating an appropriate
// container element and setting innerHTML on it, as well
// as working around various IE quirks.
return $jq.parseHTML(html, DOMBackend.getContext()) || [];
if (_hasJQuery) {
return $jq.parseHTML(html, DOMBackend.getContext()) || [];
}
const template = document.createElement('template');
template.innerHTML = html;
return Array.from(template.content.childNodes);
};

// WeakMap for native event delegation: elem -> Map<handler, Array<{wrapper, eventType}>>
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' };

DOMBackend.Events = {
// `selector` is non-null. `type` is one type (but
// may be in backend-specific form, e.g. have namespaces).
// Order fired must be order bound.
delegateEvents: function (elem, type, selector, handler) {
$jq(elem).on(type, selector, handler);
},
delegateEvents(elem, type, selector, handler) {
if (_hasJQuery) {
$jq(elem).on(type, selector, handler);
return;
}

undelegateEvents: function (elem, type, handler) {
$jq(elem).off(type, '**', handler);
let eventType = DOMBackend.Events.parseEventType(type);
// Alias non-bubbling events to their bubbling equivalents
eventType = _delegateEventAlias[eventType] || eventType;

const wrapper = (event) => {
// event.target can be a text node (nodeType 3) — walk to parent element first
const origin = event.target;
const target = origin.nodeType === 1 ? origin.closest(selector) : origin.parentElement?.closest(selector);
if (target && elem.contains(target)) {
// Mimic jQuery's delegated event behavior
Object.defineProperty(event, 'currentTarget', {
value: target,
configurable: true,
});
handler.call(target, event);
}
};

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, []);
}
handlerMap.get(key).push({ wrapper, eventType });

elem.addEventListener(eventType, wrapper);
},

bindEventCapturer: function (elem, type, selector, handler) {
const $elem = $jq(elem);

const wrapper = function (event) {
event = $jq.event.fix(event);
event.currentTarget = event.target;

// Note: It might improve jQuery interop if we called into jQuery
// here somehow. Since we don't use jQuery to dispatch the event,
// we don't fire any of jQuery's event hooks or anything. However,
// since jQuery can't bind capturing handlers, it's not clear
// where we would hook in. Internal jQuery functions like `dispatch`
// are too high-level.
const $target = $jq(event.currentTarget);
if ($target.is($elem.find(selector)))
handler.call(elem, event);
};
undelegateEvents(elem, type, handler) {
if (_hasJQuery) {
$jq(elem).off(type, '**', handler);
return;
}

handler._meteorui_wrapper = wrapper;
const handlerMap = _delegateMap.get(elem);
if (!handlerMap) return;

const entries = handlerMap.get(handler);
if (!entries) return;

for (const entry of entries) {
elem.removeEventListener(entry.eventType, entry.wrapper);
}
handlerMap.delete(handler);
},

bindEventCapturer(elem, type, selector, handler) {
if (_hasJQuery) {
const $elem = $jq(elem);

const wrapper = (event) => {
event = $jq.event.fix(event);
event.currentTarget = event.target;
const $target = $jq(event.currentTarget);
if ($target.is($elem.find(selector)))
handler.call(elem, event);
};

handler._meteorui_wrapper = wrapper;
} else {
const wrapper = (event) => {
// event.target can be a text node — walk to parent element first
const origin = event.target;
const matched = origin.nodeType === 1 ? origin.closest(selector) : origin.parentElement?.closest(selector);
if (matched && elem.contains(matched)) {
Object.defineProperty(event, 'currentTarget', {
value: matched,
configurable: true,
});
handler.call(elem, event);
}
};

handler._meteorui_wrapper = wrapper;
}

type = DOMBackend.Events.parseEventType(type);
// add *capturing* event listener
elem.addEventListener(type, wrapper, true);
elem.addEventListener(type, handler._meteorui_wrapper, true);
},

unbindEventCapturer: function (elem, type, handler) {
unbindEventCapturer(elem, type, handler) {
type = DOMBackend.Events.parseEventType(type);
elem.removeEventListener(type, handler._meteorui_wrapper, true);
},

parseEventType: function (type) {
parseEventType(type) {
// strip off namespaces
const dotLoc = type.indexOf('.');
if (dotLoc >= 0)
Expand Down Expand Up @@ -130,23 +200,39 @@ class TeardownCallback {
stop() { this.unlink(); }
}

// Shared helper: execute all teardown callbacks on an element
function _executeTeardownCallbacks(elem) {
const callbacks = elem[DOMBackend.Teardown._CB_PROP];
if (callbacks) {
let elt = callbacks.next;
while (elt !== callbacks) {
elt.go();
elt = elt.next;
}
callbacks.go();
elem[DOMBackend.Teardown._CB_PROP] = null;
}
}

DOMBackend.Teardown = {
_JQUERY_EVENT_NAME: 'blaze_teardown_watcher',
_CB_PROP: '$blaze_teardown_callbacks',
// Registers a callback function to be called when the given element or
// one of its ancestors is removed from the DOM via the backend library.
// The callback function is called at most once, and it receives the element
// in question as an argument.
onElementTeardown: function (elem, func) {
onElementTeardown(elem, func) {
const elt = new TeardownCallback(func);

const propName = DOMBackend.Teardown._CB_PROP;
if (! elem[propName]) {
if (!elem[propName]) {
// create an empty node that is never unlinked
elem[propName] = new TeardownCallback;

// Set up the event, only the first time.
$jq(elem).on(DOMBackend.Teardown._JQUERY_EVENT_NAME, NOOP);
// Set up the jQuery event, only the first time (only when jQuery is present).
if (_hasJQuery) {
$jq(elem).on(DOMBackend.Teardown._JQUERY_EVENT_NAME, NOOP);
}
}

elt.linkBefore(elem[propName]);
Expand All @@ -155,46 +241,43 @@ DOMBackend.Teardown = {
},
// Recursively call all teardown hooks, in the backend and registered
// through DOMBackend.onElementTeardown.
tearDownElement: function (elem) {
tearDownElement(elem) {
const elems = [];
// Array.prototype.slice.call doesn't work when given a NodeList in
// IE8 ("JScript object expected").
const nodeList = elem.getElementsByTagName('*');
for (let i = 0; i < nodeList.length; i++) {
elems.push(nodeList[i]);
}
elems.push(elem);
$jq.cleanData(elems);
}
};

$jq.event.special[DOMBackend.Teardown._JQUERY_EVENT_NAME] = {
setup: function () {
// This "setup" callback is important even though it is empty!
// Without it, jQuery will call addEventListener, which is a
// performance hit, especially with Chrome's async stack trace
// feature enabled.
},
teardown: function() {
const elem = this;
const callbacks = elem[DOMBackend.Teardown._CB_PROP];
if (callbacks) {
let elt = callbacks.next;
while (elt !== callbacks) {
elt.go();
elt = elt.next;
if (_hasJQuery) {
// jQuery's cleanData triggers the special event teardown handler
$jq.cleanData(elems);
} else {
// Native path: call teardown callbacks directly
for (const el of elems) {
_executeTeardownCallbacks(el);
}
callbacks.go();

elem[DOMBackend.Teardown._CB_PROP] = null;
}
}
};

// Register jQuery special event only when jQuery is present
if (_hasJQuery) {
$jq.event.special[DOMBackend.Teardown._JQUERY_EVENT_NAME] = {
setup() {
// This "setup" callback is important even though it is empty!
// Without it, jQuery will call addEventListener, which is a
// performance hit, especially with Chrome's async stack trace
// feature enabled.
},
teardown() {
_executeTeardownCallbacks(this);
}
};
}


// Must use jQuery semantics for `context`, not
// querySelectorAll's. In other words, all the parts
// of `selector` must be found under `context`.
DOMBackend.findBySelector = function (selector, context) {
return $jq(selector, context);
if (_hasJQuery) return $jq(selector, context);
return Array.from((context || document).querySelectorAll(selector));
};