Skip to content

Instantly share code, notes, and snippets.

@blizzardengle
Last active November 10, 2025 16:26
Show Gist options
  • Select an option

  • Save blizzardengle/85875262ccf217ed38b4f189eab024d1 to your computer and use it in GitHub Desktop.

Select an option

Save blizzardengle/85875262ccf217ed38b4f189eab024d1 to your computer and use it in GitHub Desktop.
A single place to watch for DOM elements. an alternative to using DOMContentLoaded or individual mutation observers all over the place.
/**
* @class DOMWatcher
* Observes the DOM for elements matching CSS selectors
*
* Monitors the DOM tree for elements that match specified selectors, triggering
* callbacks when matching elements are added. Handles both immediate detection
* of existing elements and observation of future additions.
*
* @example
* // Create a watcher instance
* const watcher = new DOMWatcher();
*
* // Watch for elements (triggers once per element)
* watcher.watch('.my-element', (element) => {
* console.log('Found element:', element);
* });
*
* // Watch continuously (callback fires for every match)
* watcher.watch('.my-element', (element) => {
* console.log('Found element:', element);
* }, false);
*
* // Watch with timeout (auto-unwatch after 5 seconds)
* watcher.watch('.my-element', (element) => {
* console.log('Found element:', element);
* }, 5000);
*
* // Manual unwatching
* const { unwatch } = watcher.watch('.my-element', callback);
* unwatch(); // Stop watching
*
* // Clean up when done
* watcher.disconnect();
*/
class DOMWatcher {
#watchersBySelector = new Map();
#selectorTimers = new Map();
#observer;
constructor() {
this.#observer = new MutationObserver(this.#handleMutations.bind(this));
this.#observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
#handleMutations(mutations) {
// Process all added nodes in a single batch
const addedNodes = new Set();
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) addedNodes.add(node);
});
});
// Check each node against selectors
addedNodes.forEach(this.#checkNode.bind(this));
}
watch(selector, callback, mode = true) {
const watchId = Symbol();
// Validate and normalize the mode parameter
let once = true;
let timeout = null;
if (mode === false) {
// Continuous watching
once = false;
} else if (typeof mode === 'number') {
// Timed watching with minimum 100ms
if (mode < 100) {
console.warn(`DOMWatcher: Timeout must be at least 100ms. Adjusting ${mode}ms to 100ms.`);
mode = 100;
}
once = false; // Don't auto-unwatch on first match
timeout = mode;
} else if (mode === true) {
// Single watch (default)
once = true;
}
if (!this.#watchersBySelector.has(selector)) {
this.#watchersBySelector.set(selector, new Map());
}
const watchers = this.#watchersBySelector.get(selector);
// For 'once' mode, track which elements this specific callback has seen
const processedElements = once ? new Set() : null;
const wrappedCallback = (element) => {
// Skip if we've already processed this element and once is true
if (once && processedElements.has(element)) {
return;
}
// Mark as processed for once mode
if (once) {
processedElements.add(element);
}
// Call the callback
callback(element);
if (once) {
this.#unwatch(selector, watchId);
}
};
watchers.set(watchId, { callback: wrappedCallback, processedElements });
// Set up timeout if specified and not already set for this selector
if (timeout !== null && !this.#selectorTimers.has(selector)) {
const timerId = setTimeout(() => {
this.#unwatchSelector(selector);
this.#selectorTimers.delete(selector);
}, timeout);
this.#selectorTimers.set(selector, timerId);
}
// Check existing elements
const existingElement = document.querySelector(selector);
if (existingElement) {
wrappedCallback(existingElement);
}
return {
unwatch: () => this.#unwatch(selector, watchId)
};
}
#unwatch(selector, id) {
const watchers = this.#watchersBySelector.get(selector);
if (watchers) {
// Delete this specific watcher (which cleans up its Set)
watchers.delete(id);
if (watchers.size === 0) {
this.#unwatchSelector(selector);
}
}
}
#unwatchSelector(selector) {
// Clear timer if exists
if (this.#selectorTimers.has(selector)) {
clearTimeout(this.#selectorTimers.get(selector));
this.#selectorTimers.delete(selector);
}
// Remove all watchers for this selector (allows Sets to be GC'd)
this.#watchersBySelector.delete(selector);
}
#checkNode(node) {
if (!node.matches) return;
this.#watchersBySelector.forEach((watchers, selector) => {
// Check if the node itself matches
if (node.matches(selector)) {
watchers.forEach((watcher) => watcher.callback(node));
} else {
// If parent doesn't match, check for first matching child
const matchingChild = node.querySelector(selector);
if (matchingChild) {
watchers.forEach((watcher) => watcher.callback(matchingChild));
}
}
});
}
disconnect() {
// Clear all active timers
this.#selectorTimers.forEach((timerId) => {
clearTimeout(timerId);
});
this.#selectorTimers.clear();
this.#observer.disconnect();
this.#watchersBySelector.clear();
}
}
export default DOMWatcher;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment