The native MutationObserver watches a subtree and hands you records of everything that changed. You can approximate it without any DOM hooks by diffing snapshots: remember which nodes were in the tree, and later compare against what's there now. What appeared is an addition; what vanished is a removal. It's the same trick a virtual-DOM reconciler or a test framework's "what re-rendered?" panel uses.
Implement mutationObserverMini(callback) with observe(root), check(), and disconnect(). check() diffs the current subtree against the last snapshot and reports added/removed nodes. See MDN: MutationObserver.
function mutationObserverMini(callback) {
return { observe, check, disconnect };
}
// callback([{ addedNodes, removedNodes }], observer)
const obs = mutationObserverMini((records) => {
console.log(records[0].addedNodes, records[0].removedNodes);
});
obs.observe(root); // snapshot the current descendants
root.appendChild(newNode);
obs.check(); // -> addedNodes: [newNode], removedNodes: []
obs.disconnect();
observe — record the set of descendant elements of root as the baseline.check — re-walk the subtree; added = current − snapshot, removed = snapshot − current. Report only when something changed, then adopt the new snapshot.root is still present, so it shows up in neither list. That's an inherent limit of snapshot diffing (call it out).