The DOMs they are a-changin’

So we’ve put in the effort to make our JavaScript code nicely unobtrusive and all is well — until it isn’t. A pesky Ajax request has messed up the DOM and suddenly there are elements in there where our unobtrusive behavioral goodness has not been attached. Never give up, never surrender. You won’t even have to get out Grabthar’s hammer, it’s only just a small matter of programming.

First, let’s shrink the problem a little. Let’s concentrate on changes effected through the abstraction layer provided by the Prototype library. Prototype implements several DOM-changing functions on the Element singleton and also mixes them into extended DOM elements. These functions are remove, update, replace, insert, wrap, and empty. Prototype also gives us the wrap function that wraps a function around another, somewhat akin to AOP‘s around advice, but without pointcuts.

Now the strategy is clear: Replace the original DOM-changing functions with versions that record a bit of information about these changes. A good mechanism to serve the actual notification is to use Custom Events. Then, anyone intersted in changes to a branch of the DOM can register as an observer for our new dom:changed event.

However, it is a good idea not to fire the event straightaway after the DOM has been changed. We might be in the middle of a batch of changes and the DOM in a correspondingly unorderly state. Besides, it is sometimes necessary to give the browser’s rendering engine a bit of breathing room after changes. For all this, at first the event data is appended to a list and a timer is set to dispatch the corresponding events some time later. This timer is reset for every change, so that for a batch of changes it only executes once and and then fires all events successively.

To understand the resulting behavior, it is important to know that JavaScript is single-threaded. As a result, when a timer times out, it does not interrupt the currently executing code. Rather, the function attached to the timer is scheduled to be executed as soon as there is nothing else to do. Therefore, the usual sequence is like this

  • An event triggers a DOM-changing function
    • Change the DOM
    • Remember the change
    • Change the DOM
    • Remember the change
  • Fire events for all remembered changes

Finally, here’s the code.

(function() {
  var methods = ['remove', 'update', 'replace', 'insert', 'wrap', 'empty'];
  var changes = [];
  var timeout;
  
  function rememberChange(element, method) {
    changes.push({
      element: $(element),
      parent: element.parentNode,
      operation: method
    });
  }
  
  function scheduleEvent() {
    if (timeout) {
      clearTimeout(timeout);
    }
    timeout = setTimeout(fireEvent, 10);
  }
  
  function fireEvent() {
    changes.each(function(change) {
      var affectedNode = change.element;
      var operation = change.operation;
      if (!change.parent || 
          (change.parent && !affectedNode.descendantOf(change.parent))) {
        affectedNode = change.parent || affectedNode.parentNode;
        operation += 'Child';
      }
      affectedNode.fire('dom:changed', {operation: operation});
    });
    changes = [];
  }

  methods.each(function(m) {
    Element.Methods[m] = Element.Methods[m].wrap(
      function(proceed, element) {
        rememberChange(element, m);
        scheduleEvent();
        return proceed.apply(null, $A(arguments).slice(1)); 
      });
  });
})();

When an element has been removed from the DOM, it is pointless to fire an event on it that bubbles upwards. There’s no surface to bubble up to anymore. Therefore, in this case, the dom:changed event is fired on the parent of the removed element and the operation is signified as removeChild.

To peek at the modification events, or do something useful with them, add code like this

document.observe('dom:loaded', function() {
  document.body.observe('dom:changed', function(event) {
    console.log('dom:changed: ', event.element(), event.memo.operation);
  });
});