Last active
November 10, 2025 16:26
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @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