From 6cad949c82f7ccd1a825dcce1f5918fb46cbc090 Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Sat, 7 Mar 2026 18:34:40 +0100 Subject: [PATCH 1/2] feat: dual-mode jQuery/native DOM backend Detect jQuery at load time. If present, all existing jQuery code paths are used (zero breaking changes). If absent, native DOM APIs are used. A deprecation message is logged when jQuery is detected. Ref #490 --- packages/blaze/dombackend.js | 259 +++++++++++++++++++++++------------ 1 file changed, 169 insertions(+), 90 deletions(-) diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index 0581b791a..50e27cf1f 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -2,84 +2,158 @@ 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() { +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" ); - base.href = document.location.href; - DOMBackend._context.head.appendChild( base ); + if (_hasJQuery) { + if ($jq.support.createHTMLDocument) { + DOMBackend._context = document.implementation.createHTMLDocument(""); + const base = DOMBackend._context.createElement("base"); + base.href = document.location.href; + DOMBackend._context.head.appendChild(base); + } else { + DOMBackend._context = document; + } } else { - DOMBackend._context = document; + // Modern browsers all support createHTMLDocument + DOMBackend._context = document.implementation.createHTMLDocument(""); + const base = DOMBackend._context.createElement("base"); + base.href = document.location.href; + DOMBackend._context.head.appendChild(base); } 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; + } + + let eventType = DOMBackend.Events.parseEventType(type); + // Alias non-bubbling events to their bubbling equivalents + eventType = _delegateEventAlias[eventType] || eventType; + + const wrapper = (event) => { + const target = event.target.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+type for later removal + const key = handler; + if (!handlerMap.has(key)) { + handlerMap.set(key, []); + } + handlerMap.get(key).push({ wrapper, eventType }); - undelegateEvents: function (elem, type, handler) { - $jq(elem).off(type, '**', handler); + 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; + } + + const handlerMap = _delegateMap.get(elem); + if (!handlerMap) return; - handler._meteorui_wrapper = wrapper; + 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) => { + const target = event.target; + if (target.closest(selector) && elem.contains(target)) { + Object.defineProperty(event, 'currentTarget', { + value: target, + 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) @@ -91,14 +165,6 @@ DOMBackend.Events = { ///// Removal detection and interoperability. -// For an explanation of this technique, see: -// http://bugs.jquery.com/ticket/12213#comment:23 . -// -// In short, an element is considered "removed" when jQuery -// cleans up its *private* userdata on the element, -// which we can detect using a custom event with a teardown -// hook. - const NOOP = function () {}; // Circular doubly-linked list @@ -109,7 +175,7 @@ const TeardownCallback = function (func) { }; // Insert newElt before oldElt in the circular list -TeardownCallback.prototype.linkBefore = function(oldElt) { +TeardownCallback.prototype.linkBefore = function (oldElt) { this.prev = oldElt.prev; this.next = oldElt; oldElt.prev.next = this; @@ -128,6 +194,20 @@ TeardownCallback.prototype.go = function () { TeardownCallback.prototype.stop = TeardownCallback.prototype.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', @@ -135,16 +215,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]); @@ -153,46 +235,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)); }; From 9a83d0aeb45f612b2b18a59a743202cf93aa76a1 Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Sat, 7 Mar 2026 23:14:41 +0100 Subject: [PATCH 2/2] fix: address Copilot review findings in native DOM backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix bindEventCapturer bug: check elem.contains(matched) instead of elem.contains(target) to prevent firing when selector matches an ancestor outside the container - Add text node guard (nodeType check) in delegateEvents and bindEventCapturer to prevent TypeError on text node targets - Deduplicate getContext() — branch only on jQuery legacy support check - Fix WeakMap comment: value is Array<{wrapper, eventType}>, not {wrapper, type} - Fix handler key comment: eventType stored in entry, not used as key --- packages/blaze/dombackend.js | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/packages/blaze/dombackend.js b/packages/blaze/dombackend.js index 50e27cf1f..37995a1a9 100644 --- a/packages/blaze/dombackend.js +++ b/packages/blaze/dombackend.js @@ -19,24 +19,16 @@ DOMBackend._hasJQuery = _hasJQuery; DOMBackend.getContext = function () { - if (DOMBackend._context) { - return DOMBackend._context; - } - if (_hasJQuery) { - if ($jq.support.createHTMLDocument) { - DOMBackend._context = document.implementation.createHTMLDocument(""); - const base = DOMBackend._context.createElement("base"); - base.href = document.location.href; - DOMBackend._context.head.appendChild(base); - } else { - DOMBackend._context = document; - } - } else { - // Modern browsers all support createHTMLDocument + 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); + } else { + DOMBackend._context = document; } return DOMBackend._context; }; @@ -50,7 +42,7 @@ DOMBackend.parseHTML = function (html) { return Array.from(template.content.childNodes); }; -// WeakMap for native event delegation: elem -> Map +// WeakMap for native event delegation: elem -> Map> const _delegateMap = new WeakMap(); // focus/blur don't bubble — use focusin/focusout for native delegation @@ -72,7 +64,9 @@ DOMBackend.Events = { eventType = _delegateEventAlias[eventType] || eventType; const wrapper = (event) => { - const target = event.target.closest(selector); + // 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', { @@ -87,7 +81,7 @@ DOMBackend.Events = { _delegateMap.set(elem, new Map()); } const handlerMap = _delegateMap.get(elem); - // Store wrapper keyed by handler+type for later removal + // Store wrapper keyed by handler for later removal (eventType stored in the entry) const key = handler; if (!handlerMap.has(key)) { handlerMap.set(key, []); @@ -130,10 +124,12 @@ DOMBackend.Events = { handler._meteorui_wrapper = wrapper; } else { const wrapper = (event) => { - const target = event.target; - if (target.closest(selector) && elem.contains(target)) { + // 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: target, + value: matched, configurable: true, }); handler.call(elem, event);