11

I've got these:

const element = this.getElementById("victim")

function releaseKraken(targetElement) {}

I want the function to be called when element is removed from DOM.

I can imagine something like this:

element.onRemove(() => releaseKraken(element))

I understand that I need MutationObserver, but all the documentation I found focuses on watching given element's children, whereas I need to watch the element itself.

UPD: the question How to detect element being added/removed from dom element? focuses on watching children of a given parent. I don't want to watch children or parent. I want to be notified when given element is removed from the DOM. Not it's children. And I don't want to set up a watcher on given element's parent (unless that's the only option) because it'll be a performance impact.

UPD2: if I set up a MutationObserver on document, this will result in the callback being triggered thousands or even millions of times per session, and each time the callback will have to filter a huge list of removed elements to see if it contains the one in question. That's just crazy.

I need something simple like I showed above. I want the callback to be triggered exactly once: when the given element is removed.

Andrey Mikhaylov - lolmaus
  • 23,107
  • 6
  • 84
  • 133
  • The only other method is the deprecated DOMNodeRemoved event. It probably won't hurt the performance since you'll observe just the node itself. – wOxxOm May 17 '18 at 12:28
  • Possible duplicate of [How to detect element being added/removed from dom element?](https://stackoverflow.com/questions/20156453/how-to-detect-element-being-added-removed-from-dom-element) – Krzysztof Janiszewski May 17 '18 at 12:30
  • Please frist do some research and then ask the question. – Krzysztof Janiszewski May 17 '18 at 12:31
  • @KrzysztofJaniszewski I've seen that question. It focuses on watching children of a parent. I want to watch given element's removal, not its children and not its parent. – Andrey Mikhaylov - lolmaus May 17 '18 at 12:31
  • What is the problem of watching on the parent for its removal? – epascarello May 17 '18 at 12:32
  • So bind it to `document` and then evry element is its children. – Krzysztof Janiszewski May 17 '18 at 12:33
  • The problem is performance. Binding on a parent (not to mention `document`) will trigger the callback for unrelated elements. I'll have to fitler them out in order to decide whether the given element was removed or some other. Depending on the number of siblings, this may be a huge performance implication. – Andrey Mikhaylov - lolmaus May 17 '18 at 12:35
  • Even worse, the callback will keep firing after the element was removed. And `MutationObserver` API does not allow unobserving individual elements. >_ – Andrey Mikhaylov - lolmaus May 17 '18 at 12:38
  • [This](https://jcubic.wordpress.com/2017/04/28/how-to-detect-if-element-is-added-or-removed-from-dom/) approach should help. – RobC May 17 '18 at 12:39
  • @RobC, you suggest watching `body`. The callback will be triggered thousands, maybe millons times per session, and on each trigger you have to iterate through a list of removed nodes in order to detect the one in question. That's crazy. I need something simple like `$(element).on('remove', e => releaseKraken(e))` that would fire exactly once. – Andrey Mikhaylov - lolmaus May 17 '18 at 12:48
  • `element.parentElement` instead of `document.body` will result in fewer iterations (if tree structure is nested). – RobC May 17 '18 at 13:03
  • Depending on how fast you need to react, polling the state in setInterval callback like every 1 second may be the most performant solution e.g. document.contains(element) or element.parentNode. – wOxxOm May 17 '18 at 13:03
  • 1
    @lolmaus-AndreyMikhaylov You want a watcher, but you don't want it to watch whole body, but it also can't watch any parent. What gives?? – Krzysztof Janiszewski May 17 '18 at 13:05
  • @RobC Your proposal will reduce the amount of unrelated triggers from millions to hundreds -- better, but still ugly. Also, you can't tell MutationObserver to unobserve individual elements (only purge all at once). This means that I'll have to use multiple MutationObservers -- one per every element I want to see removed. – Andrey Mikhaylov - lolmaus May 17 '18 at 13:09
  • @KrzysztofJaniszewski I don't want a watcher specifically. I want a simple solution for a simple, narrow task. The solution you suggest has unreasonable overhead. The fact that you can't think of a better solution does not make my question invalid. – Andrey Mikhaylov - lolmaus May 17 '18 at 13:11
  • 1
    Agreed - it's still ugly! and yes good point about MutationObserver purging all at once to unobserve - again ugly too. – RobC May 17 '18 at 13:15
  • Wouldn't it be easier to add an event when something removes it? Or just poll it and see if it is there. – epascarello May 17 '18 at 13:21
  • @epascarello 1. No, my use case involves watching arbitrary elements in a huge SPA. 2. Polling is a valid workaround, but also quite ugly. – Andrey Mikhaylov - lolmaus May 17 '18 at 13:23
  • are you in a position to touch the code that's actually removing the element? – Jan Wendland May 17 '18 at 13:23
  • Sounds like you really want something like the Custom Elements v1 `disconnectedCallback` (without actual Custom Elements of course!). – RobC May 17 '18 at 13:26
  • @JanWendland Yes, but the goal is to observe arbitrary elements. There are many thousands of DOM-mutating events, many of them template-driven, I can't hack into them all. – Andrey Mikhaylov - lolmaus May 17 '18 at 13:43

4 Answers4

12

As you said, MutationObserver only allows you to detect when the children of an element are manipulated. That means you'll need to listen to the parent and check what changes were made to see if the target element was removed.

function onRemove(element, callback) {
  const parent = element.parentNode;
  if (!parent) throw new Error("The node must already be attached");

  const obs = new MutationObserver(mutations => {
    for (const mutation of mutations) {
      for (const el of mutation.removedNodes) {
        if (el === element) {
          obs.disconnect();
          callback();
        }
      }
    }
  });
  obs.observe(parent, {
    childList: true,
  });
}

then with your example instead of

element.onRemove(() => releaseKraken(element));

you can do

onRemove(element, () => releaseKraken(element));

This approach should be plenty fast if all you are doing is watching a single element. While it may seem like a decent amount of looping, it is pretty rare for removedNodes to be more than one node, and unless something is removing tons of siblings all at once, mutations is going to be quite small too.

You could also consider doing

callback(el);

which would allow you to do

onRemove(element, releaseKraken);
loganfsmyth
  • 156,129
  • 30
  • 331
  • 251
  • Thank you, @loganfsmyth. This is exactly what I'm using so far, looks like it's the lesser of evils. – Andrey Mikhaylov - lolmaus May 17 '18 at 18:35
  • please consider this edit to your answer (with my SO score, I can't propose it without applying immediately): https://gist.github.com/lolmaus/98d8c95fbd1e0a2fa59aa77a574163b9/revisions – Andrey Mikhaylov - lolmaus May 17 '18 at 18:38
  • 1
    Hey, @loganfsmyth! We ran into an issue with this solution. The problem is that when a node is removed, only that node and none of its children appear in `removedNodes`. Since any of given element's parents can be removed, instead of `if (el === element)` we need somethings like `$(givenElement).parents().toArray().includes(removedNode)`. Also, we must watch `document` and not immediate parent, and include `subtree: true` into `observe()` options. – Andrey Mikhaylov - lolmaus May 28 '18 at 12:12
  • 1
    Yup, true. You never really specified how it was getting removed, so I assumed it would be a normal removal of that specific element. – loganfsmyth May 28 '18 at 15:49
3

An alternative, shorter version of loganfsmyth's excellent solution:

function onRemove(el, callback) {
  new MutationObserver((mutations, observer) => {
    if(!document.body.contains(el)) {
      observer.disconnect();
      callback();
    }
  }).observe(document.body, { childList: true });
}

Usage is the same:

onRemove(myElement, function() {
  console.log("The element was removed!");
})
joe
  • 3,752
  • 1
  • 32
  • 41
1

Here is a solution I found on this page

document.getElementById("delete_one_div").addEventListener('click', function() {
  var divToDelete = document.getElementsByTagName("div")[0];
  divToDelete.parentNode.removeChild(divToDelete);
});

var element = document.getElementById("div_to_be_watched")
var in_dom = document.body.contains(element);
var observer = new MutationObserver(function(mutations) {
  if (in_dom && !document.body.contains(element)) {
    console.log("I was just removed");
    in_dom = false;
    observer.disconnect();
  }

});
observer.observe(document.body, { childList: true });
<div id="test">Test</div>
<div id="div_to_be_watched">Div to be watched</div>
<div class="div_to_be_watched">Second test</div>
<button id="delete_one_div">Delete one div</button>

EDIT

I edited snippet a little bit. You have two options:

  1. Use it the way it is. And it is not very memory consuming since the if condition is not really complicated (only to check whether the body contains an element) and it observes only to the moment of remove and then it stops,
  2. Make observer observe only specific element to limit the event triggers.
Krzysztof Janiszewski
  • 3,763
  • 3
  • 18
  • 38
0

To observe if a specific element was removed from DOM you can use the following function. (Remove the export keyword if you don't want to use it as an ES6 module.) Cheers!

// Usage example:
// observeRemoval(document.children[0], doSomething, 'document.children[0] removed') => doSomething('document.children[0] removed') will be executed if document.children[0] is removed
// observeRemoval(document.children[0], doSomething) => doSomething(document.children[0]) will be executed if document.children[0] is removed (removed element is passed to callback function)
// observeRemoval([document.children[0], document.children[1]], doSomething) => doSomething(document.children[0]) will be executed if document.children[0] is removed (removed element is passed to callback function), doSomething(document.children[1]) will be executed if document.children[1] is removed
// observeRemoval([document.children[0], document.children[1]], doSomething, ['document.children[0] removed', 'document.children[1] removed']) => doSomething('document.children[0] removed') will be executed if document.children[0] is removed, doSomething('document.children[1] removed') will be executed if document.children[1] is removed
// observeRemoval(document.querySelectorAll('body *'), doSomething) => doSomething(<removed-element>) will be executed if any element inside the document.body is removed

export function observeRemoval(elements, callback, callbackInputs){
  let ecr = ecrTransform(elements, callback, callbackInputs);
  for(let i=0;i<ecr.elements.length;i++){
    let match = removalObserved.find(obj => obj.element === ecr.elements[i] && obj.callback === ecr.callback && obj.callbackInput === ecr.callbackInputs[i]);
    if(!match){
      removalObserved.push({
        element: ecr.elements[i],
        callback: ecr.callback,
        callbackInput: ecr.callbackInputs[i],
      });
    }
  }
}

export function unobserveRemoval(elements, callback, callbackInputs){
  let ecr = ecrTransform(elements, callback, callbackInputs);
  for(let i=0;i<removalObserved.length;i++){
    let index = removalObserved.findIndex(obj => obj.element === ecr.elements[i] && obj.callback === ecr.callback && obj.callbackInput === ecr.callbackInputs[i]);
    if(index > -1){
      removalObserved.splice(index, 1);
    }
  }
}

export function getRemovalObservers(elements, callback, callbackInputs){
  return removalObserved;
}

export function disconnectRemovalObserver(){
  removalObserver.disconnect();
}

export function connectRemovalObserver(){
  removalObserver.observe(document, {childList: true, subtree: true});
}

function ecrTransform(elements, callback, callbackInputs){
  elements = transformNodeListHTMLCollectionToArray(elements);
  callbackInputs = transformNodeListHTMLCollectionToArray(callbackInputs);
  if(!Array.isArray(elements)){
    elements = [elements];
  }
  if(!Array.isArray(callbackInputs)){
    callbackInputs = [callbackInputs];
    if(callbackInputs[0] === undefined){
      callbackInputs[0] = elements[0];
    }
  }
  if(elements.length > callbackInputs.length){
    // let continuouscallbackInput = callbackInputs[callbackInputs.length-1];
    // for(let i=0;i<(elements.length - callbackInputs.length);i++){
    //   callbackInputs.push(continuouscallbackInput);
    // }
    let continuouscallbackInput = callbackInputs[callbackInputs.length-1];
    for(let i=(elements.length - callbackInputs.length);i<elements.length;i++){
      callbackInputs.push(elements[i]);
    }
  }
  return {elements, callback, callbackInputs};
}
function transformNodeListHTMLCollectionToArray(list){
  if(NodeList.prototype.isPrototypeOf(list) || HTMLCollection.prototype.isPrototypeOf(list)){
    return Array.from(list);
  }
  return list;
}

const removalObserved = [];
const removalObserver = new MutationObserver(mutations => {
  for(let m=0;m<mutations.length;m++){
    for(let i=0;i<mutations[m].removedNodes.length;i++){
      let dO = removalObserved;
        for(let j=0;j<dO.length;j++){
        if(mutations[m].removedNodes[i].contains(dO[j].element) && !document.contains(dO[j].element)){
          (dO[j].callbackInput !== undefined) ? dO[j].callback(dO[j].callbackInput) : dO[j].callback(dO[j].element);
        }
      }
    }
  }
});
connectRemovalObserver();
SYNAIKIDO
  • 69
  • 1
  • 4