1

I'm trying to reuse fragments like this, but the event listener only works once and is no longer there when the fragment gets reused:

https://codepen.io/dvtan/pen/dyyGVKm

let $home = $(`
<div>
  <h1>Home page</h1>
  <button class="btn btn-primary">Go to another page</button>
</div>`);

let $another = $(`
<div>
  <h1>Another page</h1>
  <button class="btn btn-primary">Go back</button>
</div>`);

$("body").html($home);

$home.find('.btn').on('click', () => {
  $("body").html($another);
});

$another.find('.btn').on('click', () => {
  $("body").html($home);
});

I thought that intuitively, the event listeners should follow the fragment around, so it doesn't make sense that they would just disappear like this. Why is it happening, and what's the solution to this?

Dave Newton
  • 158,873
  • 26
  • 254
  • 302
davidtgq
  • 3,780
  • 10
  • 43
  • 80
  • Because an event listener is only assigned to elements which actually exist when the listener is created. It cannot account for things which are only added later. Read the documentation for jQuery's ".on()" function, specifically the section about "delegated" events, for a way you can work round that – ADyson Oct 12 '19 at 21:41
  • Well yeah I know that, that's why I'm reusing the fragment which has an event listener already attached. I'm saying that the event listener should intuitively follow the fragment, even if the fragment is removed and reused later. – davidtgq Oct 12 '19 at 21:44
  • When you write something like `$("body").html($home);` you are replacing the whole page with a new piece of HTML. So all previous listeners are destroyed. And your fragment itself does not have event listeners attached directly...it starts as a piece of text. It doesn't become a DOM element until you add it to the body. So when you then overwrite the body with something else then that element is destroyed in the DOM and goes back to just being some text inside a jQuery object. Since it's DOM elements which have listeners attached, that's likely the reason for your problem – ADyson Oct 12 '19 at 22:00
  • @ADyson But if it's DOM elements which have listeners attached, then how is it possible to attach event listeners to fragments that aren't in the DOM yet? – davidtgq Oct 13 '19 at 01:24
  • Hm yes now I look at it a bit more closely that doesn't make a huge amount of sense on the face of it. I'll have a think. Do you just want to understand why this is happening, or are you looking for an alternative way to implement the requirement? – ADyson Oct 13 '19 at 13:41
  • @ADyson I'd like to understand why. I have a few alternatives in mind but none of them are as elegant, because extra functions or extra variables need to be kept track of. – davidtgq Oct 13 '19 at 15:34
  • @ADyson did you want to add anything to this? – davidtgq Nov 07 '19 at 19:12
  • @ADyson: Consider this: btn1 = document.createElement('button'); btn1.appendChild(document.createTextNode('first')); btn1.addEventListener('click', function(){console.log('first')}); btn2 = document.createElement('button'); btn2.appendChild(document.createTextNode('second')); btn2.addEventListener('click', function(){console.log('second')}); document.body.appendChild(btn1). Then you have a button logging 'first' to the console. By document.body.replaceChild(btn2, btn1) you switch to having 'second' logged to the console, and by document.body.replaceChild(btn1, btn2), you can switch back. – mathheadinclouds Dec 11 '19 at 20:46
  • @ADyson: the fact that the "new" button overwrites the event listeners of the "old" button does not imply that the "new document" doesn't have any event handlers. I think you interpreted `$("body").html($home)` as `$home` being a string - in which case the "new document" indeed wouldn't have any event handlers. But $home is $(... some string...) which is not itself a string. – mathheadinclouds Dec 11 '19 at 20:50

2 Answers2

2

Here is a what you're trying to do in vanilla javascript:

var body = document.body;
var pages = {};
['home', 'other'].forEach(function(page){
  var p = pages[page] = document.getElementById(page);
  body.removeChild(p);
});

go = function(where){
  body.innerHTML = '';
  body.appendChild(pages[where]);
}

go('home');
<body>
<div id='home'>
  <h1>Home Page</h1>
  <button onclick="go('other')">Go to another Page</button>
</div>
<div id='other'>
  <h1>Another Page</h1>
  <button onclick="go('home')">Go Home</button>
</div>
</body>

I think you expected the call to jQuery method .html() have the same effect of what the go() method in above vanilla code is doing. Throwing out the old HTML, and putting in the fresh content - not actually HTML (=string), but rather a dom tree, with handlers already in it. That expectation is not at all unreasonable - but jQuery does not fulfill it, and I'm going to explain why that is.

The issue is NOT with the new stuff having handlers (jQuery is fine with that); the issue is with the OLD stuff being thrown out. .html(), when throwing stuff out, doesn't just remove it from the Dom tree and forgets about it; it assumes (unlike .detach(), see below) that that old stuff isn't something you're going to re-insert later, but trash, which must be 'incinerated' in order not to cause memory leaks.

From the docs:

When .html() is used to set an element's content, any content that was in that element is completely replaced by the new content. Additionally, jQuery removes other constructs such as data and event handlers from child elements before replacing those elements with the new content.

The way in which old dom nodes can cause memory leaks is this: Data associated with the dom nodes via the jQuery method .data() is not stored in the node itself, but in a central jQuery-interal 'cache' - the node just has a reference into the cache, so the node can find its data. If you delete the node the vanilla way (parent.removeChild()), the data of the old trash node will stay in the jQuery data cache and waste space there. I'm not sure how event handlers have similar issues as the above, but I'm sure their removal has a similar reason. That memory leak issue is discussed further here and in the upvoted comment to this question.

Now, in your code, the event handlers aren't set as attributes (onclick), but with .on, which is translated to the vanilla addEventListener. And those (see quote from jQuery docs) are incinerated when .html() removes the old content.

It is true that coding would be a little easier in some cases, if jQuery omitted above incineration, but then, the resulting memory leak issues would be way worse (because they are way harder to find compared to your little issue here), so the bad would outweigh the good, if jQuery were to live up to your expectations. (side note: if my memory doesn't fail me, there are old jQuery versions which behave that way, but don't quote me on that.)

Workarounds are manifold. The version closest to your original code (and above vanilla version, but using jQuery) is to circumvent the incineration, by using .detach() which is the jQuery way of removing something from the dom tree without incinerating it. Then, indeed, 'event handlers follow the fragment around'.

Something like this:

var pages = {}, showing = null;
['home', 'other'].forEach(function(page){
  pages[page] = $('#' + page).detach();
});
go = function(where){
  if (showing){ pages[showing].detach(); }
  $('body').append(pages[where]);
  showing = where;
}

go('home')
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<body>
<div id='home'>
  <h1>Home Page</h1>
  <button onclick="go('other')">Go to another Page</button>
</div>
<div id='other'>
  <h1>Another Page</h1>
  <button onclick="go('home')">Go Home</button>
</div>
</body>

I'm not sure if I agree, but most people don't use .detach(), but instead let the incineration happen, and then recreate the handlers, like this (and, here, event handlers do not 'follow around'):

let homeHTML = `
<div>
  <h1>Home page</h1>
  <button class="btn btn-primary">Go to another page</button>
</div>`;

let anotherHTML = `
<div>
  <h1>Another page</h1>
  <button class="btn btn-primary">Go back</button>
</div>`;

function setHome(){
  $("body").html(homeHTML);
  $("body").find('.btn').on('click', setAnother);
}
function setAnother(){
  $("body").html(anotherHTML);
  $("body").find('.btn').on('click', setHome);
}
setHome();
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

The jQuery team made the right decision with above incineration. But it does have a downside, and your question puts the finger on it.

Both above workarounds have something unsatisfactory (or 'ugly', if you're as extreme a perfectionist as I am). detach-append are two steps, and .html-.on are also two steps, but the whole thing should be one step. Content change in a single page app should be a one-liner akin to variable assignment. Such as (in pseudo code) library.set(body, 'otherPage') or body <- otherPage or so. The jQuery .html() command isn't that one-liner. That it isn't, is not a bug, but not a beauty contest winning achievement either.

mathheadinclouds
  • 3,507
  • 2
  • 27
  • 37
  • Thanks for looking into this, now I know I'm not crazy lol. Made it into an issue on github: https://github.com/jquery/jquery/issues/4564 – davidtgq Dec 12 '19 at 06:38
  • 1
    Some people seem to think that you cannot attach handlers to elements which are not part of the dom tree; or that the `.html` method ignores handlers which are attached to some detached fragment given to `.html` as an argument, as if converting the argument to a string before inserting into the document. Both of those are wrong: https://jsfiddle.net/mathheadinclouds/uhr18o3y/ – mathheadinclouds Dec 16 '19 at 14:03
  • 1
    Some people seem to think that when `.html` is called not with a string, but with`$(...some string...)`, jQuery would do the same as if you gave that string as the argument, and create a fresh copy of dom nodes specified by that html string. That is not true. The dom node creation happens when `$(...some string...)` is called, and not repeated when `.html` is called. Fiddle: https://jsfiddle.net/mathheadinclouds/L2wq4yzj/ – mathheadinclouds Dec 16 '19 at 14:20
  • more evidence that memory leaks are the sole reason for jQuery removing data and event handlers when .html is called: look at this question https://stackoverflow.com/questions/3955229/remove-all-child-elements-of-a-dom-node-in-javascript and search on the page for 'memory leaks' – mathheadinclouds Dec 16 '19 at 14:50
  • 1
    if you make a very simple change to the OP's code, namely, before each call to `.html()`, you insert `document.body.innerHTML = ''`, then the code will work. This is what I'm doing in the fiddle from my first comment here (again: https://jsfiddle.net/mathheadinclouds/uhr18o3y/). This hack turns off the 'incineration' I described in my answer. Thus, the failure of the OP's code to work is entirely owed to the fact that `$("body").html(...)` and `document.body.innerHTML=''; $("body").html(...)` do **different** things. It's not a bug, but counter-intuitive, and hence the question is a good one. – mathheadinclouds Dec 18 '19 at 12:53
0

This is expected behavior when injecting dynamic HTML into a document, and is not a 'bug' in jQuery.. This happens because when you inject dynamic HTML, you have to rebind the event and event handler. The easiest way to accomplish this would be to use:

// This is the important part ||
//                            \/ can be an #id or .class
$(document).on('click', "#some-id", function(event) {
  someHandler(); // event handler goes here
});

You can read more here

let homeHTML = `
<div>
  <h1>Home page</h1>
  <button id="nav-button" class="btn btn-primary">Go to another page</button>
</div>`;

let anotherHTML = `
<div>
  <h1>Another page</h1>
  <button id="nav-button" class="btn btn-primary">Go back</button>
</div>`;

function navigatePage(element, html, handler) {
  $("body").html(html);
  $(document).on('click', element, handler);
}

function setHome() {
  navigatePage("#nav-button", homeHTML, setAnother);
}

function setAnother() {
  navigatePage("#nav-button", anotherHTML, setHome);
}

setHome();
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
Matt Oestreich
  • 8,219
  • 3
  • 16
  • 41
  • It isn't just jQuery though.. How do you expect JavaScript to bind events to HTML before it even exists? [See here](https://codepen.io/oze4/pen/eYmdpzy?editors=1010) - you have the same exact issue with Vanilla JavaScript.... it's not a jQuery issue or a JavaScript issue.. this is 100% normal, expected behavior. – Matt Oestreich Dec 13 '19 at 16:50
  • And [this is how you would fix](https://codepen.io/oze4/pen/BayLopK?editors=1010) it using Vanilla JavaScript.. the same exact way you resolve it using jQuery... Again.. this isn't a jQuery issue or a JavaScript issue.. this is 100% expected behavior and is not a bug or 'ugly' or whatever you think it is. – Matt Oestreich Dec 13 '19 at 16:54
  • You're doing the exact same thing as what I'm doing.. but instead of using HTML text (in a string literal), you are programmatically creating DOM nodes... It's no different, and still requires you to have an Event Listener on the button. – Matt Oestreich Dec 13 '19 at 17:22
  • the difference is how often does the code run which binds the event handler to the node. In my case, once, right after the dom node is created. In the "standard" case, it run as often as the fragment is inserted into the document. that **IS** the difference. So don't say there is no difference. – mathheadinclouds Dec 13 '19 at 17:25
  • 1
    @mathheadinclouds but with that being said you can just do this. https://codepen.io/oze4/pen/JjoRGEd – Matt Oestreich Dec 13 '19 at 18:13
  • Yes, I am serious - it's bound and you don't have to re-bind the even on each click...and even after cleaning up your code it is still difficult as all hell to understand. It's not intuitive at all. Its actually pretty bad. – Matt Oestreich Dec 15 '19 at 02:09
  • The fact of the matter is, it's a million times easier to put HTML code into a string literal, and handle things that way - its far easier to read, far shorter, far more scalable. – Matt Oestreich Dec 15 '19 at 02:36
  • I mean all your doing anyway is creating nodes programmatically - the outline remains the same - you still have to do `div( h1("Some Title") )`, etc.. etc.. which is a lot harder to scale and keep track of - so yes, agree to disagree. – Matt Oestreich Dec 15 '19 at 02:39
  • What you created is like [`Mithril.js`](https://mithril.js.org/).. – Matt Oestreich Dec 15 '19 at 02:50
  • And I applaud your efforts, but I showed the same exact thing in a far more legible, shorter, elegant fashion. – Matt Oestreich Dec 15 '19 at 02:52
  • My example has 435 characters - yours has 1,111 even after removing all the comments. So yes, my code is shorter... and if you're not including your "library" code in the mix then that isn't apples to apples (not to mention my example doesn't need a library....) – Matt Oestreich Dec 15 '19 at 03:03