Search code examples
javascriptdomdom-eventsinnerhtmlmutation-observers

The MutationObserver is not triggered when innerHTML is used by the documentElement node


I've discovered the MutationObserver interface for intercepting and analyzing changes in the DOM.

It works very well and I've managed to intercept all DOM changes... with the exception of the DOM change via the innerHTML method when it's the documentElement node that uses it.

Here is my code:

function test() {

  const config = {
    subtree: true,
    childList: true
  };

  const callback = (mutationList) => {
    for (const mutation of mutationList) {
      if (mutation.type === "childList") {

        for (const node of mutation.addedNodes) {
          if (node.tagName && node.tagName.toLowerCase() === "div") {
            console.log(node);
          }
        }
      }
    }
  };
  const observer = new MutationObserver(callback);
  observer.observe(document.documentElement, config);
};

test();

document.documentElement.innerHTML += '<div>TEST</div>';
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>MutationObserver Example</title>
</head>

<body>
</body>

</html>

The following JavaScript code:

document.documentElement.innerHTML += '<div>TEST</div>';

appears to completely rewrite the HEAD and BODY nodes. This explains why the MutationObserver interface fails to intercept the addition of a <div> tag.

I'd like to know how to intercept in this case, the addition of a <div> tag to the DOM.


Solution

  • The mutation record is triggered:

    function test() {
      const config = {
        subtree: true,
        childList: true
      };
    
      const callback = (mutationList) => {
        observer.disconnect();
        // StackSnippet's console's components have been removed by the innerHTML setter
        console.log(mutationList);
        document.body.append("Mutation triggered, check your browser's console");
      };
      const observer = new MutationObserver(callback);
      observer.observe(document.documentElement, config);
    };
    document.querySelector("button").onclick = evt => {
      test();
      document.documentElement.innerHTML += '<div>TEST</div>';
    };
    <button>Change &lt;html>'s innerHTML</button>

    What happens is that for the HTML parser, the <html> element can only have 2 Element children: a <head> and a <body>. No matter what input you give to the parser, it will produce at least the tree

    #Document
     <html>
      <head></head>
      <body></body>
     <html>
    

    If you check the produced tree after your call, you'll see that indeed the <html> will contain an empty <head>, and a <body> which contains the <div>TEST</div> content:

    document.documentElement.innerHTML = `<div>TEST</div>`;
    
    const pre = document.createElement("pre");
    pre.textContent = document.documentElement.outerHTML;
    document.body.append(pre);

    So in the MutationRecord you catch, there is no <div> in the list of addedNodes because the ones that have actually be added are the <head>, a text node, and the <body>, which will contain your <div>. If you want to catch these, you need to check the content of the addedNodes.

    // StackSnippet's console is disabled by the code below
    // So we implement our own "logger".
    const log = (content) => {
      const pre = document.createElement("pre");
      pre.textContent = content;
      document.body.append(pre);
    };
    
    function test() {
      const config = {
        subtree: true,
        childList: true
      };
      const callback = (mutationList) => {
        observer.disconnect(); // trigger once
        for (const { addedNodes } of mutationList) {
          for (const node of addedNodes) {
            log(`Created node: ${node.nodeName}`);
            for (const div of node.querySelectorAll?.("div") || []) {
              log(`Added a new DIV: ${div.outerHTML}`);
            }
          }
        }
      };
      const observer = new MutationObserver(callback);
      observer.observe(document.documentElement, config);
    };
    document.querySelector("button").onclick = evt => {
      test();
      document.documentElement.innerHTML += '<div>TEST</div>';
    };
    <button>Change &lt;html>'s innerHTML</button>

    Now it should be noted that it is possible to append() a <div> as a direct child of the <html>, so you may want to add a line that checks that the addedNode .matches?.("div"), but if this happens, something is certainly messed up somewhere.