Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

MutationObserver's DOM context is lost because it fires too late

Simplified version of my code:

<div id="d">text<br><hr>text</div>

<script>
    // Called when DOM changes.
    function mutationCallback(mutations) {
        // assert(mutations.length === 3);
        var insertImg = mutations[0];
        console.log(insertImg.previousSibling.parentNode);  // Null!
        console.log(insertImg.nextSibling.parentNode); // Null!
        // Can't determine where img was inserted!
    }
  
    // Setup
    var div = document.getElementById('d');
    var br = div.childNodes[1];
    var hr = div.childNodes[2];
    var observer = new MutationObserver(mutationCallback);
    observer.observe(div, {childList: true, subtree: true});

    // Trigger DOM Changes.
    var img = document.createElement('img');
    div.insertBefore(img, hr);
    div.removeChild(hr);
    div.removeChild(br); // mutationCallback() is first called after this line.
</script>

I am using Mutation Observers to capture DOM changes, to update one document instance when another changes. Because the mutation observer function is not called until after <img>'s previous and next sibling are removed, the mutationCallback function can't tell where it was inserted. Reproduced in Chrome, FF, and IE11.

An alternative is to traverse the whole document to find changes, but that would negate the performance advantage of using Mutation Observers.

like image 578
EricP Avatar asked Mar 13 '18 18:03

EricP


3 Answers

The mutations array is a full list of mutations that have happened for a particular target. That means, for an arbitrary element, the only way to know what the parent was at the time of that mutation you'd have to look through the later mutations to see when the parent was mutated, e.g.

var target = mutations[0].target
var parentRemoveMutation = mutations
 .slice(1)
 .find(mutation => mutation.removedNodes.indexOf(target) !== -1);
var parentNode = parentRemoveMutation 
  ? parentRemoveMutation.target // If the node was removed, that target is the parent
  : target.parentNode; // Otherwise the existing parent is still accurate.

As you can see though, this is hard-coded for the first mutation, and you'd likely have to perform it for each item in the list one at a time. This won't scale very well as since it has to do linear searches. You could also potentially run through the full mutation list to build up that metadata first.

All that said, it seems like the core of the issue here is that you really shouldn't care about the parent in an ideal world. If you are synchronizing two documents for instance, you could consider using a WeakMap to track elements, so for each possible element, have a mapping from the document being mutated to each element in the synced document. Then when mutations happen, you can just use the Map to look up the corresponding element in the original document, and reproduce the changes on the original document without needing to look at the parent at all.

like image 86
loganfsmyth Avatar answered Nov 19 '22 02:11

loganfsmyth


In the comments, you say that your goal is to clone changes from one document to another. As loganfsmyth suggests, the best way to do that would be to keep a (Weak)Map mapping the original nodes to their clones, and update that map each time you clone a new node. That way, your mutation observer can process the mutations one at a time, in the order they appear in the mutation list, and perform the corresponding operation on the mirror nodes.

Despite what you claim, this should not be particularly complicated to implement. Since a single snippet often speaks more than a thousand words, here's a simple example that clones any changes from one div to another:

var observed = document.getElementById('observed');
var mirror = document.getElementById('mirror');

var observer = new MutationObserver( updateMirror );
observer.observe( observed, { childList: true } );

var mirrorMap = new WeakMap ();

function updateMirror ( mutations ) {
  console.log( 'observed', mutations.length, 'mutations:' );
  
  for ( var mutation of mutations ) {
    if ( mutation.type !== 'childList' || mutation.target !== observed ) continue;
    
    // handle removals
    for ( var node of mutation.removedNodes ) {
      console.log( 'deleted', node );
      mirror.removeChild( mirrorMap.get(node) );
      mirrorMap.delete(node);  // not strictly necessary, since we're using a WeakMap
    }
    
    // handle insertions
    var next = (mutation.nextSibling && mirrorMap.get( mutation.nextSibling ));
    for ( var node of mutation.addedNodes ) {
      console.log( 'added', node, 'before', next );
      var copy = node.cloneNode(true);
      mirror.insertBefore( copy, next );
      mirrorMap.set(node, copy);
    }    
  }
}

// create some test nodes
var nodes = {};
'fee fie foe fum'.split(' ').forEach( key => {
  nodes[key] = document.createElement('span');
  nodes[key].textContent = key;
} );

// make some insertions and deletions
observed.appendChild( nodes.fee );  // fee
observed.appendChild( nodes.fie );  // fee fie
observed.insertBefore( nodes.foe, nodes.fie );  // fee foe fie
observed.insertBefore( nodes.fum, nodes.fee );  // fum fee foe fie
observed.removeChild( nodes.fie );  // fum fee foe
observed.removeChild( nodes.fee );  // fum foe
#observed { background: #faa }
#mirror { background: #afa }
#observed span, #mirror span { margin-right: 0.3em }
<div id="observed">observed: </div>
<div id="mirror">mirror: </div>

At least for me, on Chrome 65, this works perfectly. The console indicates that, as expected, the mutation observer callback is called once, with a list of six mutations:

observed 6 mutations:
added <span>fee</span> before null
added <span>fie</span> before null
added <span>foe</span> before <span>fie</span>
added <span>fum</span> before <span>fee</span>
deleted <span>fie</span>
deleted <span>fee</span>

As a result of mirroring these mutations, both the original div and its mirror end up with the spans "fum" and "foe" in that order.

like image 2
Ilmari Karonen Avatar answered Nov 19 '22 03:11

Ilmari Karonen


The better idea is to check the addedNodes and removedNodes array. They contain Nodelist of HTML elements with previousSibling and nextSibling property pointing to exact previous and next element right now after mutation.

Change

    var insertImg = mutations[0];

To

    var insertImg = mutations[0].addedNodes[0];

<div id="d">text<br><hr>text</div>

<script>
    // Called when DOM changes.
    function mutationCallback(mutations) {
        // assert(mutations.length === 3);
        var insertImg = mutations[0].addedNodes[0];
        console.log(insertImg);
        console.log(insertImg.previousSibling);
        console.log(insertImg.nextSibling);
    }
  
    // Setup
    var div = document.getElementById('d');
    var br = div.childNodes[1];
    var hr = div.childNodes[2];
    var observer = new MutationObserver(mutationCallback);
    observer.observe(div, {childList: true, subtree: true});

    // Trigger DOM Changes.
    var img = document.createElement('img');
    d.insertBefore(img, hr);
    d.removeChild(hr);
    d.removeChild(br); // mutationCallback() is first called after this line.
</script>
like image 1
Munim Munna Avatar answered Nov 19 '22 02:11

Munim Munna