Search code examples
javascripthtmlcssmutation-observers

MutationObserver callback only when all resources have loaded


Requirement

I need to dynamically update the content of an element, and then perform an animation once the element and its content are loaded. In this particular case the element has an opacity of 0, and once the content is loaded I set its opacity to 1 with a CSS transition to fade it in. The element's content has a large number of images, in both <img> tags and as background-images of sub elements. It is very important that the animation is only allowed to start once all the images and background-images have loaded. Otherwise the fade-in effect is broken, as empty placeholder elements are animated in with their image content appearing an instant later, as shown below.

page load

Partial Solutions

To determine when the element's content is loaded I am using a MutationObserver to observe the element and fire a callback that changes its opacity when a change is detected.

var page = document.getElementById( "page" );
var observer = new MutationObserver( function(){
    page.style.opacity = "1"; // begin animation on change
} );
var config = { characterData: true,
               attributes: false,
               childList: false,
               subtree: true };

observer.observe( page, config );
page.innerHTML = newContent; // update content

However, by itself a MutationObserver is not a comprehensive solution to the requirement. As the MDN page states:

The MutationObserver method observe() configures the MutationObserver callback to begin receiving notifications of changes to the DOM that match the given options.

DOM changes fire once the subtree is modified and when all resources that would prevent the DOM from being parsed are loaded. This does not include images. Thus, MutationObservers will fire the callback without regard as to whether the content's images are fully loaded.

If I set the childList parameter to true, within the callback I could look at every child that changes and attach a load event listener to each <img>, keeping track of the total number that I attach. Once all the load events have fired I would know that every image has loaded.

var page = document.getElementById( "page" ),
    total = 0,
    counter = 0;

var callback = function( mutationsList, observer ){
    for( var mutation of mutationsList ) {
        if ( mutation.type == 'childList' ) {
            Array.prototype.forEach.call( mutation.target.children, function ( child ) {
                if ( child.tagName === "IMG" ) {
                    total++; // keep track of total load events
                    child.addEventListener( 'load', function() {
                        counter++; // keep track of how many events have fired
                        if ( counter >= total ) {
                            page.style.opacity = "1";
                        }
                    }, false );
                }
            } );
        }
    }
}

var observer = new MutationObserver( callback );
var config = { characterData: true,
              attributes: false,
              childList: true,
              subtree: true };

observer.observe( page, config );
page.innerHTML = newContent; // update content

However, there are elements with background images that must also be loaded. Since load cannot be attached to such elements, this solution is useless when detecting if background-images have loaded.

Problem Statement

How can I use a MutationObserver to determine when all resources of a dynamically updated element are loaded? In particular, how can I use it to ascertain that implicitly dependent resources such as background images have loaded?

If this cannot be achieved with MutationObservers, how else could this be achieved? Using setTimeout to delay the animation is not a solution.


Solution

  • A solution was suggested in the comments to detect the background images loading. Although the exact implementation was not suitable as I'm not using jQuery, the method was transferable enough to work with a MutationObserver.

    The MutationObserver must detect changes to the element and all its children. Every child that changes must be investigated. If the child is an image then a load event listener was attached to it, keeping track of the total number that I attach. If the child was not an image, it was checked to see if it had a background image. If so, a new image was created, a load event listener attached as above, and the src of the image was set to the child's backgroundImage url. Browser caching meant that once the new image had loaded, so too would the background image.

    Once the number of load events that fired was equal to the total number of events attached, I knew that all images were loaded.

    const page = document.getElementById( "page" )
    let total = 0
    let counter = 0
    
    const mutCallback = ( mutationsList, observer ) => {
        for( const mutation of mutationsList ) {
            if ( mutation.type === 'childList' ) {
                for ( const child of mutation.target.children ) {
                    const style = child.currentStyle || window.getComputedStyle( child, false )
    
                    if ( child.tagName === "IMG" ) {
                        total++ // keep track of total load events
                        child.addEventListener( 'load', loaded, false )
    
                    } else if ( style.backgroundImage !== "none" ) {
                        total++; // keep track of total load events
                        const img = new Image()
                        img.addEventListener( 'load', loaded, false )
                        // extract background image url and add as img src
                        img.src = style.backgroundImage.slice( 4, -1).replace(/"/g, "" )
                    }
                } );
            }
        }
        function loaded() {
            counter++; // keep track of how many events have fired
            if ( counter >= total ) {
                // All images have loaded so can do final logic here
                page.style.opacity = "1";
            }
        }
    }
    
    const observer = new MutationObserver( mutCallback )
    const config = {
        characterData: true,
        attributes: false,
        childList: true,
        subtree: true
    }
    
    observer.observe( page, config );
    
    // update content, and now it will be observed
    page.innerHTML = newContent;
    

    This solution seems to be a bit of a hack. A new case needs to be implemented for each type of resource. For example, if I also needed to wait until scripts had loaded, I would have to identify the scripts and attach load event listeners to them, or if I had to wait for fonts to load I would have to write a case for them.

    It would be great if there was a native function to determine when all resources have loaded, rather than attaching event listeners to everything. Service workers look interesting, but for now I think the hackish solution is the easiest and most reliable.