Skip to content

Instantly share code, notes, and snippets.

@jakearchibald
Created December 14, 2012 14:26
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jakearchibald/4285799 to your computer and use it in GitHub Desktop.
Save jakearchibald/4285799 to your computer and use it in GitHub Desktop.

Fun with Mutation Observers

I’ve been having a play around with Mutation Observers this morning, trying to work out when notifications happen and what happens when removing a node that was just added.

If you’re unfamiliar with Mutation Observers, they let you receive notifications when an element, or elements, have been modified in a particular way (here's an intro to Mutation Observers from Mozilla).

Observing mid-parsing

Consider this:

<!DOCTYPE html>
<html>
  <head></head>
  <body>
    <script>
      var Observer = window.MutationObserver || window.WebKitMutationObserver;
      var observer = new Observer(function(records) {
        console.log(records);
      });
      
      observer.observe(document.body, {
        childList: true,
        subtree: true
      });
    </script>
    <div id="test"></div>
    I wonder...
    <script>
      console.log('Hello');
    </script>
  </body>
</html>

Here I’m observing changes to the body element’s childNodes collection, and any descendant of body’s childNodes collection. What do you think will be logged? Answers will be revealed in the next exciting paragraph...

The horrifying truth is the browsers disagree. WebKit calls our observer no times. Firefox calls it twice, once for the text, div#test, text, script nodes, and another for the whitespace after the final script. Note: Firefox and WebKit are the only browser that support MutationObserver, and even then WebKit only supports it with a prefix.

You could argue that Webkit is right, because the mutations here aren’t done through the DOM, so a DOM mutation observer shouldn’t be informed. I wouldn’t argue that.

At the point at which the observer is created, the childNodes of body has two items, a whitespace text node and a script element. When the document loads, that same property has seven items. Since I’ve asked the observer to tell me about child list changes, it feels like a bug that I haven’t been told about these changes. I can’t pin down a part of the spec that WebKit isn’t following, but in terms of expectation, I think Firefox is winning.

Hmm, I’m still really new at Google, am I allowed to say Firefox is doing something better? Just in case:

var Observer = window.MutationObserver || window.WebKitMutationObserver;
var observer = new Observer(function(records) {
  alert('BAM');
});

observer.observe(document.body, {
  childList: true
});

The above freezes the whole Firefox UI. You have to force-close.

Phew, hopefully HR have stopped printing that P45.

So WebKit only observes stuff added via the DOM?

It seems that way, regular parsing that isn’t DOM-initiated doesn’t trigger the observer callback. Oh, of course, there’s our favourite half-way house between parsing and DOM interaction:

var Observer = window.MutationObserver || window.WebKitMutationObserver;
var observer = new Observer(function(records) {
  console.log(records);
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

document.write('<div id="docwritten"></div>');

Here's we're adding an element via the kinda-DOM, document.write.

Once again, WebKit observes nothing. Firefox does what I expect, and tells the observer about the document written node.

The impact of removing what's added

Perhaps these observers could be used as a filter to things entering the DOM. If something arrives that you don’t like, remove it. But is the observer called quickly enough to do that?

function toArray(arrayLike) { return Array.prototype.slice.call(arrayLike); }
var Observer = window.MutationObserver || window.WebKitMutationObserver;
var observer = new Observer(function(records) {
  toArray(records).forEach(function(record) {
    if (record.addedNodes) {
      toArray(record.addedNodes).forEach(function(node) {
        if (node.classList.contains('remove-me')) {
          node.parentNode.removeChild(node);
        }
      })
    }
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

var script = document.createElement('script');
script.className = 'remove-me';
script.src = '/whatever.js';
document.body.appendChild(script);

Here I’m adding a script to the document (dynamically, so WebKit plays nice with the observer), but I’m removing it in my observer. Will the script execute?

Yep, both browsers agree, the script downloads and executes. Removing an element in an observer is not akin to cancelling an event (not that the old mutation events were cancellable). Anyway, more tests!

Adding to the code above:

var div = document.createElement('div');
div.className = 'remove-me';
div.style.background = 'url(img.png)';
document.body.appendChild(div);

Here I’m adding a div with a background image, and once again removing it in the observer. Do we get a request for the image?

No! So, what’s different here? Different things trigger requests at different points. Images for example trigger a request as soon as the source is set:

new Image().src = 'whatever.png';

The above requests the image, it doesn’t need to be in the DOM. Script elements on the other hand don’t trigger a request until they hit an element that’s connected to the documentElement, which is what we saw earlier. CSS assets are different once again, they only download if a rendered element requires them, which is why backgrounds aren’t downloaded for display: none elements.

My assumption is the div is removed before it hits the rendering process and therefore calculates style. Actually, let’s prove that with a slight modification to our node-removing code:

var Observer = window.MutationObserver || window.WebKitMutationObserver;
var observer = new Observer(function(records) {
  toArray(records).forEach(function(record) {
    if (record.addedNodes) {
      toArray(record.addedNodes).forEach(function(node) {
        if (node.classList.contains('remove-me')) {
          console.log(node.offsetWidth);
          node.parentNode.removeChild(node);
        }
      })
    }
  });
});

The difference is, before I remove the div with the background image, I ask it what its offsetWidth is. Now we do get a request for the background image.

This may seem counter-intuitive, how does querying width trigger an image to download? There’s no requirement on the background image to calculate width. Indeed, but querying width causes the browser to calculate page layout earlier than it usually would, which includes recalculating style and seeing the visible div element that specifies a background image, thus triggering the download.

More tests! (without that offsetWidth bit)

var link = document.createElement('link');
link.className = 'remove-me';
link.rel = 'stylesheet';
link.href = '/style.css';
document.body.appendChild(link);
body {
  background: url('/whatever.png');
}

Here I’m adding a stylesheet link and removing it in the observer. The stylesheet sets a background image on the body.

This is a mixture of the previous two test cases, and unsurprisingly it behaves like a mixture of the two. The stylesheet is downloaded, but not the background image. Once again, throwing offsetWidth into the mix causes the background image to download.

##So, is this behaviour according to spec?

To be honest, I’m not sure. You may be better at reading the spec than me, but I can’t find the part that defines when the MutationObserver queue is automatically processed. It certainly doesn’t define what happens to request-triggering elements removed in an observer.

Digging through the WebKit source, it looks like the queue is flushed straight after script processing. It looks like Firefox does the same, according to how it split the first example up into two observer calls, splitting on the second script element. Of course, you should never depend on your observation records being grouped in a particular way, you should be prepared for them to arrive in one delivery, or many.

Anyway, what was supposed to be a short experiment with MutationObservers threw up more questions than I was expecting! I better go & file some tickets...

@DBJDBJ
Copy link

DBJDBJ commented Dec 15, 2012

"MutationObservers" ? I was doing R&D in this in 2009 ;) jQuery (and others) have a bit naive design assumption expecting that DOM page tree does not change. Ever.
Lookee here young friend: gist:4289825 ...

@annevk
Copy link

annevk commented Dec 15, 2012

You need to look at the HTML specification to find out when the callbacks are invoked. And WebKit's behavior for parsing is an acknowledged bug.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment