permalink

9

Detect, Undo And Redo DOM Changes With Mutation Observers

mutation

I’ve been wondering why DOM Mutation Observers, which can monitor changes to one or more DOM elements and return a report of what changed, aren’t used more often in the wild.

Mutation Observers have long-replaced DOM Mutation Events (which were deemed slow, verbose and crashy), are fairly well documented and a polyfill for IE9 is available for those targeting older browsers. That said, minus some cross-browser quirks, according to ChromeStatus.com, only 0.03% of Chrome page loads use the feature.

So, why don’t they get more love? From the questions asked on StackOverflow, most of its usage comes from Chrome extensions and WYSIWYG editors, but I think they can be used for much more – for example, Undo/Redo of mutations made to the DOM (e.g undoing items on a TodoMVC list – see below) or even view transitions.

For those that haven’t played with Mutation Observers before, here’s a quick brain dump of their capabilities.

Mutation Events vs. Mutation Observers

DOM observers differ from the old Mutation Events in one special way: they’re async and deliver mutations in batches asynchronously at the end of a micro-task rather than immediately after they occur. As Mutation Observers are async by design, they wait for the current stack to be empty before calling the callback we specify.

This is useful because our callback isn’t called every single time a change is made to the DOM but only after all of the changes have been completed (or “soon”). This avoids the loss of synchrony as you don’t need to be concerned with FOUC before your observer has a chance to react.

How do Mutation Observers work?

For the tl;dr crowd, here’s a quick snippet of how to detect DOM changes:

// Setup a new observer to get notified of changes
var observer = new MutationObserver(mutationCallback);

// Observe a specific DOM node / subtree
observer.observe(node, config);

// Stop observing changes
observer.disconnect();

// Empty the queue of records
observer.takeRecords();

If you’re like me, you’ll want to actually run some code. Below are two more practical examples of modifying the DOM via contentEditable and straight-up innerHTML to give you a taste of how to work with the output of Mutation Observers.

http://jsbin.com/xazok/1/edit

image_0

and http://jsbin.com/bajuqi/1/edit

image_1

Some of the types of changes we can observe are additions & removals of child nodes, parent/child relationships, changes to attributes and updates to child text nodes. Mutation Observers don’t receive callbacks for every single change. What happens is they receive periodic callbacks for a group of changes. The group might contain multiple changes or just a single change. It’s a lot like an announcement log.

So, let’s break down our basic code sample a little further. The constructor for the MutationObserver needs a callback function specified that can be used to notify us about mutations. This can be setup as follows:

var observer = new MutationObserver(function (mutations) {
    // Whether you iterate over mutations..
    mutations.forEach(function (mutation) {
      // or use all mutation records is entirely up to you
      var entry = {
        mutation: mutation,
        el: mutation.target,
        value: mutation.target.textContent,
        oldValue: mutation.oldValue
      };
      console.log('Recording mutation:', entry);
    });
  });

Runnable demo of the above: http://jsbin.com/cogid/1/edit

We have a function that receives MutationRecord objects in the form of an array, which we log out via the console, and can do whatever we would like with this data. Although above we demonstrate use of target and oldValue above, there are many more available:

  • addedNodes gives you a NodeList of elements, attributes or text nodes added

  • removedNodes similarly gives you the list of these that are removed from the tree

  • previousSibling returns the previous sibling node

  • nextSibling returns the next sibling node

  • attributeName returns the name of the attribute(s) changed

  • and oldValue gives you the value pre-mutation and type the type of mutation (attribute, characterData or childList) affected

We can next use the observe() method to register the callback on the nodes we want to observe and specify what information we would like to be notified of.

observer.observe(target, options);

What does the second argument here refer to? Well, it’s an object of options that specifies the types of mutations we are interested in being notified of. We can specify changes about the data to include in MutationRecords that we are informed of as well. This is where you care about telling the browser what you need and to not give you anything other than that. One possible set of options might look like this:

var options = { 
  subtree: true, 
  childList: true, 
  attributes: false 
};

Possible configuration options:

  • childList: *true if mutations to children are to be observed
  • attributes: true if mutations to attributes are to be observed
  • characterData: true if data is to be observed
  • subtree: true if mutations to both the target and descendants are to be observed
  • attributeOldValue: true if attributes is true & attribute value prior to mutation needs recording
  • characterDataOldValue: true if characterData is true & data before mutations needs recording
  • attributeFilter: an array of local attribute names if not all attribute mutations need recording

So, we have an options object. We will also need to specify what DOM node we would like observed. It’s possible to observe the document root, but ideally, observe a smaller subtree of what is needed as this will lead to fewer performance issues.

Let’s say we have an editable text-area powered by contentEditable. It could be defined with some simple markup (e.g. <div id="editor” contentEditable></div>).

First, let’s query the DOM for this element:

var editor = document.querySelector('#editor');

Which we can then observe using our MutationObserver:

observer.observe(editor, config);

Whenever the editor is updated (i.e. the DOM is changed), the callback we specified when defining our observer is called and notified of those changes.

image_2

Exciting! If at any time we wanted to stop observing the node, we could call the disconnect() method on it:

observer.disconnect();

Which will effectively stop all registrations on it. We won’t go into too much more depth on the Mutation Observer API, but both Dev.Opera and MDN have good docs on the feature worth reading.

Next, let’s talk about implementing an Undo feature for DOM mutations. It’s going to be fun.

Ab(using) Mutation Observers To Implement Undo/Redo

File this one under experiments.

Everyone knows what it means to Undo or Redo a change in an application. As we don’t have a standardized API for doing this in the platform yet (UndoManager, I’m looking at you) we usually end up having to implement a stack for travelling back and forth through our edit history on our own.

There’s been much discussion lately about state management in the community and the ease of being able to implement something like an Undo/Redo queue using Om, a ClojureScript library working over Facebook’s React. As Om uses Clojure’s baked-in persistent data structures, the concept of Undo/Redo is almost free as discussed by the author, David Nolen. You get the concept of..almost for free?

I’m interested in getting this done in vanilla JS without the need for React or Clojure specifically, so why don’t we see if we can achieve this with Mutation Observers? We’ll technically be abusing the fact that their callbacks are run at the end of change micro-tasks, but who doesn’t like to abuse JS now and then?

Step 1

Let’s set the stage. In our app.html, imagine we have a contentEditable area, allowing a user to freely type or paste in formatted text as well as two buttons – Undo and Redo.

Although we’re going to look at text-editing as an example, the same concepts we’ll cover could be used to implement Undo/Redo for any piece of UI mutating the DOM (view changes, adding items to a Todo list and so forth).

So here’s our HTML:

<div id='text' contentEditable='true'></div>
<button id='undo'>Undo</button>
<button id='redo'>Redo</button>

and the beginnings of our JS:

// We’ll alias `$` to `querySelector` because, reasons.
var $ = document.querySelector.bind(document);
var text = $('#text');
var undo = $('#undo');
var redo = $('#redo');

What we want to implement is the ability to hit an Undo or Redo button so that we can go through the history of our edits, undoing or redoing edits as we please.

There are two pieces we’ll need for our implementation: a mechanism for knowing when the DOM tree in our editor has changed and some machinery for undoing and redoing any task. We’ll use Mutation Observers for the first part and Undo.js, a library that offers a simple abstraction for Undo/Redo created by Jörn Zaefferer. A new instance of an Undo.js undo stack can be setup as follows:

var stack = new Undo.Stack();

Step 2

Coincidentally, Undo.js ships with a contentEditable demo (I’ve posted it to JSBin) that demonstrates how to Undo/Redo, but implements this by relying on the browser’s onkeyup event and polling. Although this snippet from the demo uses jQuery, you should be able to grok what’s going on:

$('#editor').bind('keyup', function () {
        clearTimeout(timer);
        timer = setTimeout(function () {
            var newValue = text.html();
            // ignore meta key presses
            if (newValue !== startValue) {
                // this could try and make a diff instead of storing snapshots
                stack.execute(new EditCommand(text, startValue, newValue));
                startValue = newValue;
            }

        }, 250);

The main line of interest here is stack.execute(new EditCommand(...)), another class under the “Undo” namespace which takes as input the DOM element being used, the old value of its content and the latest value.

So, we want to define our own EditCommand. Below, you’ll see the expanded implementation of it where we define a constructor, an undo method and a redo method which simply take care of setting the innerHTML value of our selected DOM element.

var EditCommand = Undo.Command.extend({
    constructor: function (textarea, oldValue, newValue) {
        this.textarea = textarea;
        this.oldValue = oldValue;
        this.newValue = newValue;
    },
    undo: function () {
        this.textarea.innerHTML = this.oldValue;
    },
    redo: function () {
        this.textarea.innerHTML = this.newValue;
    }
});

Great. So, we know we’re going to require the use of an Undo stack and an EditCommand. Let’s go about replacing that onkeyup handler with a version using Mutation Observers instead.

Step 3

First, define the Mutation Observer which will report back our mutations:

var newValue = '';
var observer = new MutationObserver(function (mutations) {
    newValue = text.innerHTML;
    stack.execute(new EditCommand(text, startValue, newValue));
    startValue = newValue;

});

To preserve fidelity with the original demo, we have a few options here. We could simply set newValue to the innerHTML value of our editor. This would simply treat the Mutation Observer as a “has something changed” hint rather than using the mutation values it offers. A solution that uses the mutation values returned could look like the following:

var observer = new MutationObserver(function (mutations) {
            mutations.forEach(function (mutation) {
                newValue = mutation.target.innerHTML;
                if (newValue !== startValue) {
                    stack.execute(new EditCommand(text, startValue, newValue));
                    startValue = newValue;
                }
            });
        }
    });

Regardless of the option we opt for, we still pass the DOM element we’re working against, the “old” value and the newValue to our EditCommand.

There’s one issue here. During Undo/Redo, any mutations you make to the editor as part of undoing a change will themselves be considered edits. To avoid this causing issues, we can either disconnect to stop observing changes, or introduce a “blocked” (read-only) state where we prevent changes being added to the Undo stack.

var blocked = false;
var observer = new MutationObserver(function (mutations) {    
  if (blocked) {
    blocked = false;
    return;
  }
  newValue = text.innerHTML;
  stack.execute(new EditCommand(text, startValue, newValue));
  startValue = newValue;        
});

We’ll also want to update the logic in our EditCommand to allow the blocked state to be set:

var EditCommand = Undo.Command.extend({
    constructor: function (textarea, oldValue, newValue) {
        this.textarea = textarea;
        this.oldValue = oldValue;
        this.newValue = newValue;
    },

    execute: function () {},
    undo: function () {
        blocked = true;
        this.textarea.innerHTML = this.oldValue;
    },

    redo: function () {
        blocked = true;
        this.textarea.innerHTML = this.newValue;
    }

});

Step 4

Next, we call the observe method on our observer to begin watching for changes to our DOM node:

observer.observe(text, {
    attributes: true,
    childList: true,
    characterData: true,
    characterDataOldValue: true,
    subtree: true
});

We’ll want to make sure that a user can only click the Undo or Redo buttons when edits are available to be undone/redone. To achieve this we can check against the stack’s canUndo() and canRedo() states as part of a stackUI function:

function stackUI() {
    redo.disabled = !stack.canRedo();
    undo.disabled = !stack.canUndo();
}

Which we’ll call every time the Undo stack knows for certain that it has been changed:

stack.changed = function () {
    stackUI();
};

Step 5

And we finally round-up our implementation by adding some simple event listeners to the Undo and Redo buttons. These will call the Undo and Redo functions on the Undo stack:

redo.addEventListener('click', function () {
    stack.redo();
});

save.addEventListener('click', function () {
    stack.undo();
});

That’s it. We’ve implemented machinery for undoing and redoing changes to contentEditable elements using Mutation Observers which could in theory be applied to any piece of UI. You can check out the source for the complete implementation or go run a demo of the concept here:

Demo: http://jsbin.com/zamacuta/5/

Note: I’ve intentionally implemented single-character undo for fun. You could debounce to only save changes every N milliseconds if you wanted.

Although there are bound to be bugs and performance edge-cases in the above, you’ll see that it it’s quite trivial to implement an Undo stack for your application using straight-up vanilla JS. Even if you don’t find the (ab)use of Mutation Observers here useful, do check-out Undo.js. It’s solid.

Beyond contentEditable

Once I finished playing around with contentEditable, I wanted to see how far I could take improving the ease of using Mutation Observers in my apps. The results were my Undo/Redo example with TodoMVC and Google Now cards.

I also decided to create a custom Web Component using Polymer called the <undo-manage> element. My goal was to minimize the boilerplate necessary for implementing that Undo feature as much as possible. So here we go:

<undo-manager>
    <div contentEditable></div>
</undo-manager>

It’s still early days but the basic idea is that any piece of DOM placed inside the tag becomes instantly observable and gets free access to Undo/Redo capabilities for mutations made to them. If you’re interested, you can see a live example of the element in action using a Circles demo.

Mutation Observer Performance

So, we’ve looked at code samples and demos, but let’s take a quick break to discuss performance.

Mutation Observers are intended to be more efficient (and more safe) to use than DOM Mutation Events, but they aren’t designed to be significantly faster. Much of their claims to efficiency are to do around how Mutation Events fire when there are changes detected. Specifically, we avoid the need to do use DOMNodeInserted with an event listener as follows:

// note: don’t use this
document.addEventListener('DOMNodeInserted', function () {
   var el = document.createElement('div');
   document.body.appendChild(el);
});

which could create a loop inevitably crashing the browser. As events could be called so often in this way and force an interruption in the browsers paint cycle (recalculating styles, layout or repaint) you would see FOUC. This problem is worsened by code that may still be executing causing more changes to be made to the DOM which could keep being interrupted by your callback.

As Mutation Events propagate in the same way as DOM events do, it’s possible to receive changes on elements you don’t quite care about. Mutation Observers don’t have this issue as they allow the browser to inform you of changes at a time that makes sense (e.g. when all JavaScript has finished executing or before the browser starts up repainting/recalc style calculations once again).

If you’re interested in reading more about this topic, check out this this Stack Overflow thread.

Note: I’ve purposefully not included my own benchmarks around Mutation Observers as their performance is nuanced and likely specific to your own application. Benchmarks for the feature can be found on jsPerf if you’re interested, though I can’t vouch for their accuracy.

Weak and strong references

I usually get asked about garbage collection and references when it comes to discussing any form of observation, so let’s talk about it relative to Mutation Observers.

Closing the tab (or window) for your page will automatically disconnect any of the Mutation Observers for elements in that document. You see, Mutation Observers hold weak references to each target node. If the node happens to get destroyed the observed no longer notifies the page about mutations.

The DOM node in question holds a strong reference to your Mutation Observer however – so if the target remains, you should probably avoid destroying observer instances without disconnecting them. Where possible, disconnect your observers on page unload.

Limitations of Mutation Observers

I’ve been talking them up, but Mutation Observers are not without their faults and the following represents some of their known limitations:

  • Mutation Observers listen for changes to DOM nodes but the current state of form elements are not accurately reflected in the DOM. They don’t reflect their “live” or “true” value back into the attribute storage. The “value” attribute is really just “defaultValue”. Similarly the internal state of elements such as the value of a <textarea> or whether <details> is collapsed is state that needs to be observed separately and is generally exposed in the form of events. For example, with input elements input.getAttribute('value') returns null rather than the current value. Observers thus can’t observe this type of change and you end up needing to write your own input/textarea change detection using input/keyup event listeners instead. Many developers I’ve seen do this rely on polling to detect when the text has changed every 50–200 ms.
  • MutationObservers are unable to detect changes to CSS styles (like hover state).
  • Timestamps for mutations are not included in the change records.
  • DOM Mutation Events allowed us to hook into DOM node insertion, keeping the call stack intact. This was problematic and came with performance costs, but with Mutation Observers, handling is postponed and batched into subtree insertions which are more efficient. It unfortunately also means some information is post, such as the execution context of the code that inserted each node.
  • In Chrome, when Mutation Observers get garbage collected there are occasionally visible consequences for users, such as custom properties set via JS getting swallowed depending on context. https://code.google.com/p/chromium/issues/detail?id=329103

Conclusions

Some may argue that state belongs out of the DOM (and this may be the case depending on your use case). That said, Mutation Observers are pretty neat and are supported in Chrome for Android, iOS 6+ and all modern evergreen browsers. Whilst they’re not without their quirks, they’re certainly worth familiarising yourself with, particularly if you care about detecting changes to the DOM in any way.

As always, if there’s an issue with the API or feedback you would like to send back to the Blink team, I’m more than happy to listen to or file bugs on your behalf. Cheers. I hope this write-up has been helpful.

image_3

With thanks to Mathias Bynens and Pascal Hartig for their reviews. Feedback and fixes are as always welcome.

9 Comments

  1. Pingback: Detect, Undo And Redo DOM Changes With Mutation Observers | TOP SEO WEBSITES DESIGN

  2. Thanks for an awesome article, as always :)
    I only have one question:

    Why is there a daisy in the chrome window!? :-O

    • This would almost certainly be simpler with immutable models or persistent data structures. Filed under experiments to test the limits of Mutation Observers.

  3. Thanks for the awesome article, I think I know one of the best use cases of undo+redo

    Shopping carts should implement it for when you add a product to your cart by mistake!

Leave a Reply

Required fields are marked *.