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).We'll snapshot the subtree's elements into a Set when observing, and on each check re-walk the tree and take the two set differences.
The native observer hooks into the DOM and tells you the instant something changes. We can get most of the value without any hooks by comparing two pictures of the tree: the set of elements at observe time (the snapshot) and the set right now (current). Anything in current but not the snapshot was added; anything in the snapshot but not current was removed. After reporting, the current picture becomes the new snapshot.
Two bags of nodes. Subtract one from the other each way. current − snapshot is what appeared; snapshot − current is what disappeared. That's the entire diff — no per-node bookkeeping, just membership.
You might only look at the direct children of the root:
function moNaive(cb) {
let root, prev;
return {
observe(r) { root = r; prev = new Set(r.children); },
check() {
const now = new Set(root.children);
const added = [...now].filter((n) => !prev.has(n));
const removed = [...prev].filter((n) => !now.has(n));
prev = now;
cb([{ addedNodes: added, removedNodes: removed }]);
},
};
}
Two issues. It watches only root.children, so adding a <div><span></span></div> reports the div but never sees the nested span — mutations happen at any depth. And it calls cb even when nothing changed. We need to walk the whole subtree and only fire on a real difference.
function mutationObserverMini(callback) {
let root = null;
let snapshot = new Set();
// All descendant elements, in document order (pre-order DFS).
function descendants(node) {
const out = [];
(function walk(n) {
for (const child of n.children) {
out.push(child);
walk(child);
}
})(node);
return out;
}
function observe(target) {
root = target;
snapshot = new Set(descendants(root));
}
function check() {
if (!root) return [];
const current = descendants(root);
const currentSet = new Set(current);
const addedNodes = current.filter((n) => !snapshot.has(n)); // ordered
const removedNodes = [...snapshot].filter((n) => !currentSet.has(n));
snapshot = currentSet; // adopt the new baseline
if (addedNodes.length === 0 && removedNodes.length === 0) return [];
const records = [{ addedNodes, removedNodes }];
callback(records, api);
return records;
}
function disconnect() {
root = null;
snapshot = new Set();
}
const api = { observe, check, disconnect };
return api;
}
module.exports = { mutationObserverMini };
The fixes over the naive version: walk the entire subtree with a pre-order DFS (so nested additions and removals are caught, and addedNodes comes out in document order), and only fire when at least one difference exists. Building addedNodes by filtering the ordered current array keeps order; removedNodes iterates the snapshot Set, whose insertion order is the original document order.
observe(root) on <p>a</p>, then append <b> and remove <p>:
descendants(root) is [p]; snapshot = {p}.root now holds <b>; p is detached.current = [b], currentSet = {b}.
addedNodes = [b] (b isn't in the snapshot).removedNodes = [p] (p isn't in current).snapshot = {b}; callback([{ addedNodes:[b], removedNodes:[p] }], api).check with no further changes: current = [b], both diffs empty → return [], no callback.check()s stay quiet.snapshot with currentSet, the next check re-reports the same change forever.characterData and attributes — the real observer also reports text edits and attribute changes; snapshot those separately (text content per node, attribute maps).check in a requestAnimationFrame or microtask loop to approximate live delivery instead of manual polling.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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).We'll snapshot the subtree's elements into a Set when observing, and on each check re-walk the tree and take the two set differences.
The native observer hooks into the DOM and tells you the instant something changes. We can get most of the value without any hooks by comparing two pictures of the tree: the set of elements at observe time (the snapshot) and the set right now (current). Anything in current but not the snapshot was added; anything in the snapshot but not current was removed. After reporting, the current picture becomes the new snapshot.
Two bags of nodes. Subtract one from the other each way. current − snapshot is what appeared; snapshot − current is what disappeared. That's the entire diff — no per-node bookkeeping, just membership.
You might only look at the direct children of the root:
function moNaive(cb) {
let root, prev;
return {
observe(r) { root = r; prev = new Set(r.children); },
check() {
const now = new Set(root.children);
const added = [...now].filter((n) => !prev.has(n));
const removed = [...prev].filter((n) => !now.has(n));
prev = now;
cb([{ addedNodes: added, removedNodes: removed }]);
},
};
}
Two issues. It watches only root.children, so adding a <div><span></span></div> reports the div but never sees the nested span — mutations happen at any depth. And it calls cb even when nothing changed. We need to walk the whole subtree and only fire on a real difference.
function mutationObserverMini(callback) {
let root = null;
let snapshot = new Set();
// All descendant elements, in document order (pre-order DFS).
function descendants(node) {
const out = [];
(function walk(n) {
for (const child of n.children) {
out.push(child);
walk(child);
}
})(node);
return out;
}
function observe(target) {
root = target;
snapshot = new Set(descendants(root));
}
function check() {
if (!root) return [];
const current = descendants(root);
const currentSet = new Set(current);
const addedNodes = current.filter((n) => !snapshot.has(n)); // ordered
const removedNodes = [...snapshot].filter((n) => !currentSet.has(n));
snapshot = currentSet; // adopt the new baseline
if (addedNodes.length === 0 && removedNodes.length === 0) return [];
const records = [{ addedNodes, removedNodes }];
callback(records, api);
return records;
}
function disconnect() {
root = null;
snapshot = new Set();
}
const api = { observe, check, disconnect };
return api;
}
module.exports = { mutationObserverMini };
The fixes over the naive version: walk the entire subtree with a pre-order DFS (so nested additions and removals are caught, and addedNodes comes out in document order), and only fire when at least one difference exists. Building addedNodes by filtering the ordered current array keeps order; removedNodes iterates the snapshot Set, whose insertion order is the original document order.
observe(root) on <p>a</p>, then append <b> and remove <p>:
descendants(root) is [p]; snapshot = {p}.root now holds <b>; p is detached.current = [b], currentSet = {b}.
addedNodes = [b] (b isn't in the snapshot).removedNodes = [p] (p isn't in current).snapshot = {b}; callback([{ addedNodes:[b], removedNodes:[p] }], api).check with no further changes: current = [b], both diffs empty → return [], no callback.check()s stay quiet.snapshot with currentSet, the next check re-reports the same change forever.characterData and attributes — the real observer also reports text edits and attribute changes; snapshot those separately (text content per node, attribute maps).check in a requestAnimationFrame or microtask loop to approximate live delivery instead of manual polling.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.