diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index 2bd22e444..ca2eba9e3 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -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> +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) @@ -130,6 +200,20 @@ 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', @@ -137,16 +221,18 @@ DOMBackend.Teardown = { // 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]); @@ -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)); };