Mutation Observer notes

My current project requires me to use Mutation Observers, and I couldn’t resist the temptation to do a little fundamental research. Overall they work fine, but there is one tricky bit when it comes to text changes. Also, I found two Edge bugs.

My current client asked me to keep track of DOM nodes that appear onmouseover — and my script should work in pretty much any site. So we have add some information to DOM nodes that appear onmouseover, which will allow a later script to figure out that there was a mouseover involved. That’s obviously a job for Mutation Observers.

Here’s Microsoft’s documentation for Mutation Observers, which I find clearer than the MDN page.

Mutation Observers on mouseovers

So let’s get to work. This is the basic idea:

var mutationConfig = {
	childList: true,
	subtree: true,
};
var observer = new MutationObserver(createRecords);

document.addEventListener('mouseover',function (e) {
	observer.observe(document.documentElement,mutationConfig);
},true);

document.addEventListener('mouseover',function (e) {
	observer.disconnect();
},false);

function createRecords() {
	// check if it's really a childList mutation
	// if so, add a few properties to the created DOM nodes
}

I create a Mutation Observer for DOM nodes (though it also catches some text changes — I’ll get back to that). The observer observes the entire document because there’s no telling where the mutation is going to take place.

Then I set two mouseover event handlers on the document; one for the capturing phase and one for the bubbling phase. We can be certain that the first one fires before any author-defined mouseover, and the last one fires after any of them (except when authors actually set mouseovers on the document itself, but that’s rare).

Thus, once a mouseover event occurs the very first thing that happens is that the Mutation Observer is switched on. Once the event has been captured by its target and then bubbled back up to the document we switch off the observer. As a result, only mutations that happen in-between, i.e. on an author-defined mouseover somewhere lower in the DOM tree, are recorded.

I’m happy to report that this works in all browsers. Still, if you think you only get true node changes you should think again.

True node changes and secret text changes

Before we continue, a quick explanation. Mutation Observers can observe three types of mutations:

  1. childList, where a DOM node is added to or removed from the document.
  2. attributes, where a DOM attribute is added, removed, or changed.
  3. characterData, where DOM nodes aren’t touched; only text content is changed.

For this particular job we decided to restrict ourselves to childList mutations: we’re only interested in new DOM nodes that appear in the document.

That sounds good, but it turns out there’s a tricky bit. Take this simple line of code:

element.innerHTML = 'A new text';

Mutation-wise, what’s going on here? Although it seems to be a straight characterData mutation, it’s not.

Let’s say initially element contains a bit of text. In the DOM, this is a text node. What innerHTML does, apparently, is remove this text node entirely and replace it with a new text node that contains the specified text. That makes sense when you think about it for a minute, but it does mean that according to the Mutation Observer this is a childList mutation: nodes are being added and removed.

Also, if element previously contained a <span> or other node, it’s now removed, and that constitutes a true childList mutation.

And what about innerText, textContent, and nodeValue? What kind of mutation do the following lines of code cause?

element.innerText = 'A new text';
element.textContent = 'A new text';
element.firstChild.nodeValue = 'A new text';

The first two are similar to innerHTML. Here, too, text nodes are removed and inserted. The last one is different: here we access a property of the element’s first child node, which is the text node. Here the text node is not removed, but only changed. Thus, browsers see the nodeValue change as a true characterData mutation.

There’s an odd exception here, though. In Chrome, and only Chrome, an innerText change is counted as characterData, provided there were no actual element nodes in element before the code ran. Firefox, Safari, and Edge treat it as a childList mutation in all cases. Why the difference? Is it a bug? I have no clue, but it is something you should be aware of.

In any case, in my script I didn’t want these not-quite-childList mutations cluttering up my records. Fortunately the solution is simple: check if any of the added or removed nodes are element nodes (nodeType === 1). If so this is a true childList mutation; if not it’s a secret characterData mutation.

if (rec.type === 'childList') {
	var isNodeChange = false;
	if (rec.addedNodes.length) {
		for (var i=0,node;node=rec.addedNodes[i];i+=1) {
			if (node.nodeType === 1) {
				isNodeChange = true;
				break;
			}
		}
	}
	if (rec.removedNodes.length && !isNodeChange) {
		for (var i=0,node;node=rec.removedNodes[i];i+=1) {
			if (node.nodeType === 1) {
				isNodeChange = true;
				break;
			}
		}
	}
	if (!isNodeChange) {
		continue; 
		// continue looping through the records, and ignore this one
	}
}

Edge bugs

Unfortunately here we find the first Edge bug. In Edge, innerHTML, innerText, and textContent changes cause not one but two mutation records: one for the removal of the old node, and one for the insertion of the new node. All other browsers have only one mutation record that contains one removed and one inserted node.

That messes up the script above. If, for instance, an element node was removed but not inserted, the insertion record would be seen as characterData, while it should be counted as childList, since the mutation does involve element nodes.

Since Microsoft confirmed this is a bug, and since Edge is not the first, or even second, target browser, we decided to ignore this bug and hope for a speedy fix.

There’s another Edge bug you should be aware of. This is the full configuration object you can send to a mutation observer:

var mutationConfig = {
	childList: true,
	subtree: true,
	characterData: true, 
	attributes: true, 
	attributeOldValue: true,
	characterDataOldValue: true,
};

You’re supposed to only observe those mutation types that you’re interested in in order to avoid a performance hit. I did that in the script above: I only observe childList mutations, though I’m forced to leave subtree on: without it, only mutations on direct children of the HTML element would be counted, and I don’t know where mutations are going to take place. So I observe the entire DOM tree.

The Edge bug concerns the last two entries:

var mutationConfig = {
	attributeOldValue: true,
	characterDataOldValue: true,
};

These tell the browsers to keep the old values of attributes or text nodes available for use. (If you don’t set these flags they’re discarded.) Since we’re telling browsers to keep track of the old text and attribute values, it stands to reason that we want them to observe those mutations, and Firefox, Safari, and Chrome do so.

Edge doesn’t. It needs explicit orders, like so:

var mutationConfig = {
	characterData: true, 
	attributes: true, 
	attributeOldValue: true,
	characterDataOldValue: true,
};

Without the first two lines, Edge does nothing. (Actually, it’s more complicated. In my first test Edge gave a JavaScript error without any error text, but in my second it did nothing. I think I moved from 15 to 16 in the mean time, but I’m not 100% sure. Thou shalt always keep a lab journal!)

Anyway, despite the Chrome oddity and the two Edge bugs, Mutation Observers appear ready for prime time.

This is the blog of Peter-Paul Koch, web developer, consultant, and trainer. You can also follow him on Twitter or Mastodon.
Atom RSS

If you like this blog, why not donate a little bit of money to help me pay my bills?

Categories: