AntiYank

Using a MutationObserver to notify our "framework" that an element that it cares about has been removed from the DOM.

by David Iglesias

HTML

<button id="addView">Add view</button>
<label for="antiYank">
  <input type="checkbox" id="protectFromYank" checked />
  Protect from Yank?
</label>

<hr />

<div id="target">
Views go here
</div>

<template id="childView">
  <div class="view">
    View ID: <span class="viewId"></span>
    <button class="remove">Remove</button>
    <button class="yank">Yank</button>
  </div>
</template>

CSS

* {
  font-family: sans-serif;
  box-sizing: border-box;
}

JavaScript

// Handles the unexpected removal (yank) of "views"
// when: data-anti-yank="true".
function handleYanks(mutations, observer) {
  mutations.forEach((mutation) => {
  	mutation.removedNodes.forEach((removedNode) => {
    	let data = removedNode.dataset;
    	if (data.antiYank && !data.removedCleanly) {
	    	let viewId = data.viewId;
      	console.log('Detected yanked view', viewId);
      	viewManager.yanked(viewId);
      }      
    })
  });
  viewManager.logLeaks();
}
let yankObserver = new MutationObserver(handleYanks);
// We cannot attach directly to the element, but its parent :(
yankObserver.observe(target, {
	childList: true,
});

// Handler of the "Add view" button
addView.addEventListener('click', (e) => {
  let viewId = viewManager.getNextViewId();
  let newChild = createChildView(viewId, protectFromYank.checked);
  viewManager.add(viewId, newChild);
});

// Delegate handler of the "Remove/Yank" buttons
target.addEventListener('click', (e) => {
  let view = e.target.closest('.view');
  if (!view) return;
  if (e.target.matches('.remove')) {
    viewManager.remove(view.dataset.viewId);
  } else if (e.target.matches('.yank')) {
  	view.remove();
  }
});

// A class that keeps a Map<viewId, domElement>
// and the children of a `root` element in sync.
class ViewManager {
  constructor(root) {
    this.viewId = 0;
    this.views = new Map();
    this.root = root;
  }

  getNextViewId() {
    return this.viewId++;
  }

  add(id, domElement) {
    this.views.set(`${id}`, domElement);
    this.root.append(domElement);
  }

  remove(id) {
    let element = this.views.get(id);
    this.views.delete(id);
    element.dataset.removedCleanly = true;
    element.remove();
    console.log('Politely removed view', id);
  }
  
  yanked(id) {
		this.views.delete(id);
    console.log('Cleaned up yanked view', id);
  }
  
  // Logs the elements in `this.views` that have been
  // yanked from the DOM.
  logLeaks() {
  	let leaked = new Map(
   ...