Appendix F: Monitoring DOM changes

DOM mutation events were introduced to HTML several years ago in order to allow web applications to monitor changes to the DOM by other scripts. Unfortunately, adding listeners for any of these events to a document has a highly deleterious effect on performance, an effect which is not mitigated in the slightest by later removing those listeners. For this reason, it is best to avoid using mutation listeners at all costs, especially from extensions. This document lays out some alternatives which do not have such severe performance impacts.

Mutation observers

Mutation observers are the more efficient, W3C speced replacement for the highly inefficient mutation events. Their major advantage is in their performance and their ease of use, especially when combined with simple libraries. Their main disadvantage is that, due to their recent advent, support is currently limited to Firefox 14+ and Chrome 18. While they are still viable for add-ons targeting only the latest Firefox, those wishing to support older browsers will need to provide fallbacks.

For more information, see this blog post on the issues at hand.

Non-mutation triggers

It is usually possible to tell when a mutation has occurred or is about to occur without resorting to mutation events or observers. Most of these methods are specific to a particular site or codebase.

hashchange and popstate events

Most AJAX-heavy sites update the URL when they significantly change their content, either via a change to the fragment identifier (hash) or more recently via the history.pushState method. The former triggers a hashchange event while the latter triggers a popstate event. Event listeners for those events are the preferred method for detecting DOM modifications when feasible.

Often times, this must be used in combination with a slight delay, or even polling, via setTimeout, for cases where the URL change happens before the page is fully loaded.

This method works particularly well with services like Google Instant, and the Gawker family of sites.

Network listeners

AJAX-based page changes are almost universally tied to XMLHttpRequests. These requests can be tracked from chrome code using a variety of methods, including web progress listeners, HTTP observers, and content policies. While these are not exceptionally efficient (they run for every HTTP request, and considerably more often for some methods), they work very well for certain applications

Pure CSS

Pure CSS can be more powerful than most people suspect. It is often possible to do things for which people have traditionally resorted to JavaScript with CSS alone. In particular, the ::before and ::after pseudo-elements, and pseudo-classes like :hover and :active can be used to achieve extremely complex dynamic behavior.

Late binding and event delegation

Often times, it suffices to bind event listeners to top-level elements and check for matching elements when the event fires. For instance, rather than watching for the creation of <a> elements and adding event listeners to them as they are created, an event listener can be added to the root <html> element, and when the event fires, the event.target and its parents can be searched for a matching element. You can read more about event delegation here.

It is important to note, however, that this method can have severe performance implications for certain events, namely mouseover, mouseout, and friends, which are fired very often while the mouse is moving.

Monkey patching

In many instances, especially when dealing with chrome code, the best way to modify actions which result in DOM mutations is by wrapping the functions that trigger those changes. For instance, when you want to modify the result of DOM mutations that you know are the result of the doAwesomeDOMStuff() function, you can wrap it as follows:

    {
      let originalDoAwesomeDOMStuff = doAwesomeDOMStuff;
      doAwesomeDOMStuff = function _doAwesomeDOMStuff() {
        let res = originalDoAwesomeDOMStuff.apply(this, arguments);
        doAwesomerDOMStuff(res, arguments);
        return res;
      };
    }

Now, whenever doAwesomeDOMStuff() is called, the original function will be called, followed by your own doAwesomerDOMStuff() function, which can then further modify the DOM as needed. Variations on this method include modifying the arguments passed to the wrapped function, modifying its return value, and making changes both before and after the original method is called. This scheme is highly flexible and can be made to work under most circumstances.

CSS animation events

This technique, which works on Firefox 5+, has some particular advantages. Because it is based on CSS, it can be keyed to match new elements of any arbitrary CSS selector. And because it is CSS-based, and is triggered when CSS is being applied anyway, it does not slow DOM mutations. The technique was adapted from the method used by the X-Tag library.

The code below contains an inefficient fallback implementation for older browsers. It is worth noting, however, that while the animation-based implementation will fire any time an element which previously did not match the given selector is changed so that it does (when a class is added, for instance), the fallback will only match nodes when they are inserted into the DOM tree. Minor changes are also required if one wishes to support other browsers, or to run in non-chrome-privileged scopes.

/**
 * Watches for nodes matching the given CSS selector to become
 * available.
 *
 * @param {string} selector The CSS selector of nodes about which you
 *     want to be notified.
 * @param {function(Event)} callback The function which is to be called
 *     when matching nodes become available.
 * @param {Document} doc The document in which to watch for new nodes.
 *     @optional @default document
 *
 * @returns {function} A function which may be called to unregister the
 * listener.
 */
if ('MozCSSKeyframeRule' in window || 'CSSKeyframeRule' in window) {
    var watchNodes = function watchNodes(selector, callback, doc) {
        const EVENT = watchNodes.PREFIX + (watchNodes._i++);
        const XHTML = 'http://www.w3.org/1999/xhtml';

        doc = doc || document;

        let style = doc.createElementNS(XHTML, 'style');
        style.setAttribute('type', 'text/css');

        let preamble = '    @-moz-keyframes ' + EVENT + ' { \n\
            from { clip: rect(1px, auto, auto, auto); } to { clip: rect(0px, auto, auto, auto); } \n\
        }\n';

        let properties = [
            'animation-duration: 0.0001s;',
            'animation-name: ' + EVENT + ' !important;'
        ];

        properties = properties.map(function (prop) '        ' + watchNodes.NAMESPACE + prop)
                               .join('\n');

        doc.addEventListener('animationstart', listener, false);
        function listener(event) {
            if (event.animationName == EVENT)
                callback.call(this, event);
        }

        style.textContent = '    ' + preamble + selector + '{' + properties + '}';
        (doc.head || doc.documentElement).appendChild(style);

        // This will only work in chrome privileged code. Content code
        // should make do without weak references.
        style = Components.utils.getWeakReference(style);

        return function unwatch() {
            if (style.get()) {
                style.get().ownerDocument.removeEventListener('animationstart', listener, false);
                style.get().parentNode.removeChild(style.get());
            }
        };
    }
    watchNodes.NAMESPACE = 'MozCSSKeyframeRule' in window ? '-moz-' : '';
    watchNodes.PREFIX    = 'keyframe-node-inserted-' + Math.floor(Math.random() + 0xffffffff) + '-';
    watchNodes._i = 0;
}
else {
    watchNodes = function watchNodes(selector, callback, doc) {
        doc = doc || document;
        doc.addEventListener('DOMNodeInserted', listener, false);
        function listener(event) {
            if (event.target.mozMatchesSelector(selector))
                callback.call(this, event);
        }

        return function unwatch() {
            doc.removeEventListener('DOMNodeInserted', listener, false);
        };
    }
}

XBL

XBL is similar in approach to the above animation-based method. Bindings are attached to DOM nodes via CSS and are given the opportunity to run code via their constructors when new matching DOM nodes are created. While it is no longer possible for web sites to use XBL directly, chrome code can still attach XBL bindings to web content from stylesheets loaded via the stylesheet service.

As it is currently possible to attach only a single binding to a given DOM node at once, it is important when using this method to detach your binding from a node once your constructor has run. This is most easily done by adding a :not clause to the CSS selector that attaches your binding. For instance, when using the selector div:not([foo-binding-done]), you can detach your binding by running this.setAttribute("foo-binding-done", true) in your constructor.

Monkey patching DOM methods

When you are interested in changes to particular nodes, sometimes the best way is to wrap the DOM methods of those elements, as in the monkey patching section above. For instance, if you need to know about attribute changes to a particular node, then you should replace its setAttribute method with a function that calls the original setAttribute function as originalSetAttribute.apply(this, arguments) before running your necessary event code.